From 972078f2c4d138fa28a0bb2d09717ebf4659cb6e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:14:55 -0700 Subject: [PATCH] Fix paste ownership, input bounds, and IPC validation Supersedes #5745, #5746, and #5747. --- config/scripts/locale-ko-key-overrides.mjs | 5 +- config/scripts/locale-ko-value-overrides.mjs | 63 +- .../session-scanner-graph-parsers.test.ts | 21 + .../ai-vault/session-scanner-graph-parsers.ts | 15 +- .../session-scanner-grok-parser.test.ts | 30 + .../ai-vault/session-scanner-grok-parser.ts | 62 +- .../session-scanner-text-normalization.ts | 306 ++++++++ .../ai-vault/session-scanner-values.test.ts | 54 ++ src/main/ai-vault/session-scanner-values.ts | 99 +-- src/main/ai-vault/session-scanner.test.ts | 46 +- .../automations/hermes-cron-output.test.ts | 42 ++ src/main/automations/hermes-cron-output.ts | 220 +++++- src/main/browser/agent-browser-bridge.test.ts | 112 ++- src/main/browser/agent-browser-bridge.ts | 70 +- .../browser/browser-cookie-import.test.ts | 14 + src/main/browser/browser-cookie-import.ts | 29 +- .../browser/browser-text-insertion.test.ts | 78 +++ src/main/browser/browser-text-insertion.ts | 77 +++ .../browser/cdp-bridge-integration.test.ts | 34 + src/main/browser/cdp-bridge.ts | 5 +- src/main/browser/grab-guest-script.test.ts | 18 +- src/main/browser/grab-guest-script.ts | 114 ++- .../computer-clipboard-paste-validation.ts | 42 ++ .../computer-provider-action-validation.ts | 14 +- .../computer-sidecar-paste-validation.ts | 15 + ...ktop-script-provider-action-errors.test.ts | 13 + .../desktop-script-provider-client.ts | 2 +- ...p-script-provider-paste-validation.test.ts | 48 ++ .../computer/macos-native-provider-client.ts | 2 +- ...s-native-provider-paste-validation.test.ts | 164 +++++ src/main/computer/sidecar-client.test.ts | 44 ++ src/main/computer/sidecar-client.ts | 5 + src/main/daemon/daemon-health.test.ts | 45 +- src/main/daemon/daemon-health.ts | 33 +- src/main/daemon/headless-emulator.test.ts | 28 +- src/main/daemon/headless-emulator.ts | 24 +- src/main/daemon/osc7-uri-extraction.test.ts | 34 + src/main/daemon/osc7-uri-extraction.ts | 81 +++ src/main/devin/hook-service.test.ts | 30 +- .../serve-sim-helper-processes.test.ts | 22 +- .../emulator/serve-sim-helper-processes.ts | 6 +- src/main/git/repo.ts | 9 +- src/main/git/runner.test.ts | 20 +- src/main/git/runner.ts | 49 +- src/main/git/status.ts | 5 +- src/main/git/worktree.ts | 10 +- src/main/github/client-work-items.test.ts | 37 + src/main/github/client.ts | 19 +- src/main/github/project-view.test.ts | 40 +- src/main/github/project-view.ts | 18 +- src/main/gitlab/gl-utils.test.ts | 13 + src/main/gitlab/gl-utils.ts | 26 +- src/main/gitlab/glab-api-response.ts | 46 ++ src/main/gitlab/glab-known-hosts.ts | 79 +++ ...omation-dispatcher-source-boundary.test.ts | 22 + src/main/hooks.test.ts | 115 ++- src/main/hooks.ts | 68 +- src/main/ipc/crash-reporting.ts | 15 +- src/main/ipc/dropped-path-resolution.test.ts | 71 +- src/main/ipc/dropped-path-resolution.ts | 19 +- .../ipc/pty-startup-barrier-ordering.test.ts | 4 +- src/main/ipc/pty.test.ts | 153 +++- src/main/ipc/pty.ts | 111 ++- src/main/ipc/register-core-handlers.test.ts | 16 +- src/main/ipc/register-core-handlers.ts | 9 +- src/main/ipc/ssh.test.ts | 49 ++ src/main/ipc/ssh.ts | 19 +- src/main/ipc/ui.test.ts | 190 +++++ src/main/ipc/ui.ts | 51 +- .../ipc/worktree-push-target-cleanup.test.ts | 29 + src/main/ipc/worktree-push-target-cleanup.ts | 44 +- src/main/linear/issue-context-errors.test.ts | 27 + src/main/linear/issue-context-errors.ts | 35 +- src/main/memory/collector.test.ts | 19 + src/main/memory/collector.ts | 14 +- src/main/menu/register-app-menu.test.ts | 13 + .../local-workspace-port-scanner.test.ts | 39 +- .../ports/local-workspace-port-scanner.ts | 8 +- src/main/providers/provider-dispatch.test.ts | 5 +- src/main/runtime/orca-runtime.test.ts | 167 ++++- src/main/runtime/orca-runtime.ts | 168 ++++- ...ime-request-connection.integration.test.ts | 14 +- .../rpc/dispatcher-computer-errors.test.ts | 43 +- src/main/runtime/rpc/dispatcher.ts | 11 +- src/main/runtime/rpc/methods/browser-core.ts | 20 +- .../runtime/rpc/methods/browser-extras.ts | 6 +- .../runtime/rpc/methods/browser-schemas.ts | 5 +- .../rpc/methods/browser-text-rpc-methods.ts | 30 + src/main/runtime/rpc/methods/browser.test.ts | 117 ++++ .../runtime/rpc/methods/clipboard.test.ts | 53 ++ src/main/runtime/rpc/methods/clipboard.ts | 49 +- .../rpc/methods/computer-actions.test.ts | 13 + src/main/runtime/rpc/methods/computer.test.ts | 9 + src/main/runtime/rpc/methods/terminal.ts | 176 +++-- .../rpc/rpc-clipboard-text-validation.ts | 19 + .../runtime/rpc/terminal-multiplex.test.ts | 3 + .../rpc/terminal-output-batching.test.ts | 87 +++ src/main/runtime/rpc/terminal-send.test.ts | 97 ++- src/main/ssh/ssh-relay-session.ts | 7 + .../ssh/ssh-remote-platform-detection.test.ts | 70 ++ src/main/ssh/ssh-remote-platform-detection.ts | 5 +- src/main/stats/agent-detector.test.ts | 47 +- src/main/stats/agent-detector.ts | 35 +- .../pull-request-context.test.ts | 35 +- .../text-generation/pull-request-context.ts | 66 +- .../attach-main-window-services.test.ts | 55 ++ .../window/attach-main-window-services.ts | 14 +- src/main/window/clipboard-image-temp-file.ts | 3 + .../window/clipboard-ipc-handlers.test.ts | 272 +++++++- src/main/window/clipboard-ipc-handlers.ts | 103 ++- src/main/window/createMainWindow.ts | 5 + src/main/window/editable-context-menu.test.ts | 58 +- src/main/window/editable-context-menu.ts | 36 +- src/preload/api-types.ts | 9 +- src/preload/index.ts | 83 ++- src/relay/fs-handler-install-rg.test.ts | 41 ++ src/relay/fs-handler-install-rg.ts | 79 ++- src/relay/git-handler-branch-cleanup.test.ts | 3 +- src/relay/git-handler-commit-diff-ops.ts | 5 +- src/relay/git-handler-worktree-ops.test.ts | 3 +- src/relay/git-handler-worktree-paths.test.ts | 3 +- src/relay/port-scan-handler.test.ts | 18 +- src/relay/port-scan-handler.ts | 3 +- src/relay/pty-shell-utils.ts | 3 +- src/relay/windows-port-scan.ts | 3 +- src/renderer/src/App.tsx | 4 + .../src/components/GitHubItemDialog.tsx | 185 +++-- .../src/components/GitLabItemDialog.tsx | 52 +- .../src/components/JiraIssueWorkspace.tsx | 24 +- .../src/components/LinearIssueWorkspace.tsx | 10 +- .../src/components/LinearItemDrawer.tsx | 31 +- .../components/NewWorkspaceComposerCard.tsx | 41 ++ .../src/components/PullRequestPage.tsx | 200 +++--- src/renderer/src/components/TaskPage.tsx | 115 ++- .../src/components/TerminalSearch.tsx | 22 +- .../activity/ActivityPrototypePage.test.ts | 30 +- .../activity/ActivityPrototypePage.tsx | 27 +- .../automations/AutomationCustomCronPanel.tsx | 7 +- .../automations/AutomationProjectCombobox.tsx | 5 +- .../automations/CreateFromPicker.tsx | 6 + .../automations/HermesCronOutputView.tsx | 86 +-- .../hermes-cron-output-parse.test.ts | 68 ++ .../automations/hermes-cron-output-parse.ts | 162 +++++ .../components/browser-pane/BrowserFind.tsx | 22 +- .../components/browser-pane/BrowserPane.tsx | 21 +- .../browser-address-bar-suggestions.test.ts | 48 +- .../browser-address-bar-suggestions.ts | 13 +- .../browser-annotation-output.test.ts | 76 +- .../browser-pane/browser-annotation-output.ts | 71 +- .../components/cmd-j/palette-results.test.ts | 61 +- .../src/components/cmd-j/palette-results.ts | 45 +- .../dictation-insertion-target.test.ts | 154 +++++ .../dictation/dictation-insertion-target.ts | 160 ++++- .../diff-comments/DiffCommentPopover.tsx | 27 +- .../diff-comments/useDiffCommentDecorator.tsx | 3 +- .../editor/CombinedDiffFileTree.test.ts | 42 ++ .../components/editor/CombinedDiffViewer.tsx | 18 +- .../components/editor/EditorContent.test.tsx | 15 +- .../src/components/editor/EditorContent.tsx | 29 +- .../src/components/editor/IpynbViewer.tsx | 9 +- .../src/components/editor/MonacoEditor.tsx | 66 +- .../components/editor/NotesSendMenu.test.tsx | 26 +- .../src/components/editor/PdfFind.tsx | 22 +- .../ReviewNotesSendMenuContent.test.tsx | 3 +- .../combined-diff-commit-message.test.ts | 44 ++ .../editor/combined-diff-commit-message.ts | 96 +++ .../editor/combined-diff-file-tree-model.ts | 15 +- .../src/components/editor/csv-parse.test.ts | 25 +- .../src/components/editor/csv-parse.ts | 56 +- .../editor/details-markdown-html.test.ts | 48 ++ .../editor/details-markdown-html.ts | 86 ++- .../components/editor/diff-line-stats.test.ts | 46 ++ .../src/components/editor/diff-line-stats.ts | 54 +- .../editor/ipynb-code-cell-lines.test.ts | 57 ++ .../editor/ipynb-code-cell-lines.ts | 78 +++ .../src/components/editor/ipynb-parse.test.ts | 50 +- .../src/components/editor/ipynb-parse.ts | 14 +- .../editor/markdown-doc-completions.test.ts | 38 +- .../editor/markdown-doc-completions.ts | 21 + .../editor/markdown-preview-search.test.ts | 19 +- .../editor/markdown-preview-search.ts | 15 +- ...kdown-reference-link-normalization.test.ts | 55 ++ .../markdown-reference-link-normalization.ts | 66 +- .../editor/markdown-rich-mode.test.ts | 31 +- .../components/editor/markdown-rich-mode.ts | 120 +++- .../editor/markdown-table-of-contents.test.ts | 17 +- .../editor/markdown-table-of-contents.ts | 43 +- .../editor/monaco-auto-height.test.ts | 53 ++ .../components/editor/monaco-auto-height.ts | 43 ++ .../monaco-conflict-decorations.test.ts | 112 ++- .../editor/monaco-conflict-decorations.ts | 139 +++- .../editor/monaco-large-text-paste.test.ts | 310 +++++++++ .../editor/monaco-large-text-paste.ts | 279 ++++++++ ...naco-markdown-doc-link-decorations.test.ts | 35 + .../monaco-markdown-doc-link-decorations.ts | 23 +- .../editor/rich-markdown-details-extension.ts | 9 +- .../editor/rich-markdown-image-insert.test.ts | 16 + .../editor/rich-markdown-image-insert.ts | 8 +- .../rich-markdown-large-text-paste.test.ts | 285 ++++++++ .../editor/rich-markdown-large-text-paste.ts | 241 +++++++ .../editor/rich-markdown-normalize.test.ts | 24 +- .../editor/rich-markdown-normalize.ts | 23 +- .../rich-markdown-paste-handler.test.ts | 62 ++ .../editor/rich-markdown-paste-handler.ts | 33 + .../editor/rich-markdown-paste-image.test.ts | 38 +- .../editor/rich-markdown-paste-image.ts | 10 +- .../rich-markdown-review-annotations.test.ts | 26 +- .../rich-markdown-review-annotations.ts | 22 +- .../rich-markdown-review-note-layout.ts | 3 +- .../rich-markdown-review-text-ranges.test.ts | 88 +++ .../rich-markdown-review-text-ranges.ts | 232 +++++-- .../editor/rich-markdown-search.test.ts | 33 + .../components/editor/rich-markdown-search.ts | 8 +- ...rich-markdown-slash-command-filter.test.ts | 60 ++ .../rich-markdown-slash-command-filter.ts | 30 + .../components/editor/useLocalImagePick.ts | 5 +- .../editor/useRichMarkdownEditorInstance.ts | 4 +- .../editor/useRichMarkdownMenuController.ts | 10 +- .../editor/useRichMarkdownSearch.ts | 18 +- .../emulator-device-frame.input.test.tsx | 66 ++ .../emulator-pane/emulator-device-frame.tsx | 27 +- .../emulator-keyboard-paste.test.ts | 143 ++++ .../emulator-pane/emulator-keyboard-paste.ts | 194 ++++++ .../emulator-screen-aria-label.ts | 16 + .../use-emulator-control-stream.ts | 13 +- .../use-emulator-screen-keyboard.ts | 86 ++- .../github-pr-reviewer-display.test.ts | 30 +- .../components/github-pr-reviewer-display.ts | 47 ++ .../github-project/ProjectPicker.tsx | 56 +- .../github-project-picker-filter.test.ts | 131 ++++ .../github-project-picker-filter.ts | 56 ++ .../github-project/slug-dialog/Comments.tsx | 22 +- .../github/GitHubIssueCommentComposer.tsx | 22 +- .../github/GitHubMarkdownComposer.tsx | 7 +- .../components/github/PRFilterPickers.test.ts | 54 ++ .../src/components/github/PRFilterPickers.tsx | 66 +- .../github-markdown-composer-preview-pane.tsx | 9 - .../github/github-markdown-image-url.test.ts | 37 + .../github/github-markdown-image-url.ts | 44 ++ .../github-mention-option-filter.test.ts | 52 ++ .../github/github-mention-option-filter.ts | 36 + ...ithub-pr-reviewer-candidate-filter.test.ts | 71 ++ .../github-pr-reviewer-candidate-filter.ts | 57 ++ .../github-work-item-assignee-filter.test.ts | 29 + .../github-work-item-assignee-filter.ts | 9 +- .../github-work-item-label-filter.test.ts | 25 + .../github/github-work-item-label-filter.ts | 10 +- .../github-work-item-option-filter-bounds.ts | 10 + .../github/pr-comment-code-context.test.ts | 85 +++ .../github/pr-comment-code-context.ts | 234 +++++++ .../src/components/github/use-image-input.ts | 19 +- .../src/components/jira-create-adf.test.ts | 50 ++ .../src/components/jira-create-adf.ts | 48 ++ .../jira-project-picker-filter.test.ts | 100 +++ .../components/jira-project-picker-filter.ts | 55 ++ .../linear-project-search-query.test.ts | 23 + .../components/linear-project-search-query.ts | 5 + .../components/linear-scope-selector.test.ts | 45 ++ .../src/components/linear-scope-selector.tsx | 51 +- ...rkspaceNameField-source-boundaries.test.ts | 8 +- .../new-workspace/SmartWorkspaceNameField.tsx | 36 +- .../smart-workspace-source-results.test.ts | 77 ++- .../smart-workspace-source-results.ts | 27 +- .../components/pr-checks-fix-prompt.test.ts | 41 +- .../src/components/pr-checks-fix-prompt.ts | 25 +- .../src/components/quick-open-search.test.ts | 31 +- .../src/components/quick-open-search.ts | 13 + .../components/right-sidebar/FileExplorer.tsx | 20 +- .../right-sidebar/SourceControl.tsx | 48 +- .../ai-vault-session-filters.test.ts | 32 + .../right-sidebar/ai-vault-session-filters.ts | 14 + .../commit-failure-summary.test.ts | 41 +- .../right-sidebar/commit-failure-summary.ts | 74 +- .../file-explorer-name-filter-projection.ts | 64 +- .../right-panel-comment-composer.tsx | 22 +- ...source-control-commit-message-rows.test.ts | 39 ++ .../source-control-commit-message-rows.ts | 24 + .../source-control-file-filter.test.ts | 63 ++ .../source-control-file-filter.ts | 66 ++ .../right-sidebar/useFileExplorerDragDrop.ts | 13 +- .../useFileExplorerRowDrag.test.ts | 60 ++ .../right-sidebar/useFileExplorerRowDrag.ts | 14 +- ...seFileExplorerVisibleRowProjection.test.ts | 46 +- .../right-sidebar/useFileSearchRunner.ts | 41 +- .../src/components/settings/AgentsPane.tsx | 142 ++-- .../src/components/settings/BaseRefPicker.tsx | 6 + .../settings/RepositoryHooksSection.tsx | 5 +- .../settings/SettingsFormControls.tsx | 30 +- .../settings/ShortcutFilterRail.test.ts | 75 ++ .../settings/ShortcutFilterRail.tsx | 20 + .../src/components/settings/ShortcutsPane.tsx | 20 +- .../settings/WorktreeSymlinksSection.tsx | 32 +- .../settings/agent-default-env-draft.test.ts | 41 ++ .../settings/agent-default-env-draft.ts | 72 ++ .../settings-form-option-filter.test.ts | 89 +++ .../settings/settings-form-option-filter.ts | 57 ++ .../settings/settings-search.test.ts | 54 ++ .../components/settings/settings-search.ts | 19 +- .../worktree-symlink-path-filter.test.ts | 87 +++ .../settings/worktree-symlink-path-filter.ts | 61 ++ .../sidebar/RemoteFileBrowser.paste.test.tsx | 117 ++++ .../components/sidebar/RemoteFileBrowser.tsx | 48 +- .../sidebar/SetupScriptPromptCardViews.tsx | 3 +- .../sidebar/WorktreeCard.lineage.test.tsx | 63 +- .../WorktreeCard.pinned-repo-icon.test.tsx | 97 +-- .../src/components/sidebar/WorktreeCard.tsx | 21 +- .../remote-file-browser-helpers.test.ts | 46 ++ .../sidebar/remote-file-browser-helpers.ts | 27 +- .../sidebar/workspace-status-drag-data.ts | 112 +++ .../sidebar/workspace-status.test.ts | 64 +- .../components/sidebar/workspace-status.ts | 62 +- .../components/skills/skills-filter.test.ts | 33 +- .../src/components/skills/skills-filter.ts | 13 + .../workspace-space-presentation.test.ts | 27 + .../workspace-space-presentation.ts | 15 +- .../components/tab-bar/query-token-match.ts | 38 +- .../tab-create-entry-classifier.test.ts | 32 + .../tab-bar/tab-create-entry-classifier.ts | 81 +-- .../tab-bar/tab-create-entry-file-matches.ts | 70 ++ .../tab-bar/tab-create-menu-options.test.ts | 43 +- .../tab-bar/tab-create-menu-options.ts | 12 + .../task-drawer-source-boundary.test.ts | 7 +- .../task-project-source-combobox.tsx | 5 +- .../components/terminal-pane/TerminalPane.tsx | 302 ++++++-- .../command-code-output-status.test.ts | 64 +- .../command-code-output-status.ts | 64 +- .../terminal-pane/command-code-prompt-text.ts | 63 ++ .../terminal-pane/keyboard-handlers.test.ts | 12 + .../terminal-pane/keyboard-handlers.ts | 4 + .../terminal-pane/osc52-clipboard.test.ts | 4 + .../terminal-pane/osc52-clipboard.ts | 41 +- .../terminal-pane/pty-connection.test.ts | 129 +++- .../terminal-pane/pty-connection.ts | 65 +- .../terminal-pane/pty-dispatcher.ts | 28 +- .../terminal-pane/pty-eager-buffer-clamp.ts | 51 ++ .../terminal-pane/pty-transport.test.ts | 157 ++++- .../components/terminal-pane/pty-transport.ts | 152 +++- .../remote-runtime-pty-batching.test.ts | 141 ++++ .../remote-runtime-pty-batching.ts | 120 +++- .../remote-runtime-pty-transport.test.ts | 191 +++++ .../remote-runtime-pty-transport.ts | 49 +- .../terminal-bracketed-paste.test.ts | 32 +- .../terminal-pane/terminal-bracketed-paste.ts | 32 +- .../terminal-clipboard-paste.test.ts | 139 +++- .../terminal-pane/terminal-clipboard-paste.ts | 69 +- .../terminal-drop-handler.test.ts | 381 ++++++++-- .../terminal-pane/terminal-drop-handler.ts | 350 +++------- .../terminal-drop-internal-handler.test.ts | 426 ++++++++++++ ...erminal-drop-internal-rejection-message.ts | 16 + .../terminal-drop-pane-resolution.ts | 38 + .../terminal-drop-path-writer.test.ts | 113 +++ .../terminal-drop-path-writer.ts | 63 ++ .../terminal-pane/terminal-drop-shell.test.ts | 72 ++ .../terminal-pane/terminal-drop-shell.ts | 44 ++ .../terminal-pane/terminal-drop-target.ts | 41 ++ .../terminal-drop-upload-report.test.ts | 53 ++ .../terminal-drop-upload-report.ts | 37 + .../terminal-drop-worktree-path.ts | 19 + .../terminal-drop-write-failure.test.ts | 44 ++ .../terminal-drop-write-failure.ts | 23 + .../terminal-handle-links.test.ts | 18 + .../terminal-pane/terminal-handle-links.ts | 37 +- .../terminal-native-file-drop.ts | 305 ++++++++ .../terminal-pane/terminal-paste-chunks.ts | 72 ++ .../terminal-paste-coordinator.test.ts | 652 ++++++++++++++++++ .../terminal-paste-coordinator.ts | 207 ++++++ .../terminal-paste-diagnostics.ts | 48 ++ .../terminal-pane/terminal-paste-errors.ts | 22 + .../terminal-pane/terminal-paste-executor.ts | 146 ++++ .../terminal-pane/terminal-paste-limits.ts | 5 + .../terminal-pane/terminal-paste-model.ts | 66 ++ .../terminal-paste-multiline-policy.test.ts | 54 ++ .../terminal-paste-operation-timeout.ts | 28 + .../terminal-paste-payload-metadata.test.ts | 71 ++ .../terminal-paste-payload-metadata.ts | 19 + .../terminal-paste-runtime.test.ts | 253 +++++++ .../terminal-pane/terminal-paste-runtime.ts | 135 ++++ .../terminal-paste-ssh-platform.ts | 10 + .../terminal-paste-target-state.test.ts | 145 ++++ .../terminal-paste-target-state.ts | 62 ++ .../terminal-programmatic-text-paste.test.ts | 187 +++++ .../terminal-programmatic-text-paste.ts | 104 +++ .../terminal-pty-paste-writer.test.ts | 28 + .../terminal-pty-paste-writer.ts | 15 + ...erminal-startup-command-classifier.test.ts | 50 ++ .../terminal-startup-command-classifier.ts | 71 ++ .../terminal-startup-command-paste.ts | 54 ++ .../terminal-url-link-hit-testing.test.ts | 49 ++ .../terminal-url-link-hit-testing.ts | 145 +++- .../use-terminal-pane-context-menu.ts | 125 +++- .../use-terminal-pane-global-effects.test.ts | 98 ++- .../use-terminal-pane-global-effects.ts | 59 +- .../components/ui/team-multi-combobox.test.ts | 61 ++ .../src/components/ui/team-multi-combobox.tsx | 37 +- src/renderer/src/constants/terminal.ts | 1 + .../src/hooks/composer-drop-owner.test.ts | 19 + src/renderer/src/hooks/composer-drop-owner.ts | 8 + .../hooks/composer-drop-upload-result.test.ts | 31 + .../src/hooks/composer-drop-upload-result.ts | 44 ++ .../src/hooks/useAppMenuPaste.test.tsx | 103 +++ src/renderer/src/hooks/useAppMenuPaste.ts | 37 + src/renderer/src/hooks/useComposerState.ts | 67 +- .../src/hooks/useGlobalFileDrop.test.ts | 27 + src/renderer/src/hooks/useGlobalFileDrop.ts | 44 ++ .../src/hooks/useLargeTextControlPaste.ts | 20 + .../hooks/usePrimarySelectionPaste.test.tsx | 212 ++++++ .../src/hooks/usePrimarySelectionPaste.ts | 38 +- src/renderer/src/i18n/locales/en.json | 78 ++- src/renderer/src/i18n/locales/es.json | 76 +- src/renderer/src/i18n/locales/ja.json | 76 +- src/renderer/src/i18n/locales/ko.json | 76 +- src/renderer/src/i18n/locales/zh.json | 76 +- .../src/lib/agent-draft-paste-content.ts | 196 ++++++ src/renderer/src/lib/agent-draft-readiness.ts | 121 ++++ .../src/lib/agent-paste-draft.test.ts | 138 +++- src/renderer/src/lib/agent-paste-draft.ts | 173 +---- .../src/lib/agent-picker-search.test.ts | 56 +- src/renderer/src/lib/agent-picker-search.ts | 64 +- .../lib/agent-session-fork-context.test.ts | 28 +- .../src/lib/agent-session-fork-context.ts | 137 +++- .../src/lib/ai-vault-session-drag.test.ts | 34 + src/renderer/src/lib/ai-vault-session-drag.ts | 26 +- src/renderer/src/lib/app-menu-paste.test.ts | 247 +++++++ src/renderer/src/lib/app-menu-paste.ts | 137 ++++ .../src/lib/browser-palette-search.test.ts | 27 +- .../src/lib/browser-palette-search.ts | 16 +- .../src/lib/comment-body-line-count.test.ts | 40 ++ .../src/lib/comment-body-line-count.ts | 21 + .../src/lib/comment-body-submit-state.test.ts | 43 ++ .../src/lib/comment-body-submit-state.ts | 62 ++ .../src/lib/file-search-selection.test.ts | 16 + src/renderer/src/lib/file-search-selection.ts | 72 +- .../src/lib/find-query-bounds.test.ts | 23 + src/renderer/src/lib/find-query-bounds.ts | 11 + src/renderer/src/lib/github-links.test.ts | 16 + src/renderer/src/lib/github-links.ts | 6 + src/renderer/src/lib/gitlab-links.test.ts | 16 + src/renderer/src/lib/gitlab-links.ts | 6 + .../src/lib/large-text-control-paste.test.ts | 240 +++++++ .../src/lib/large-text-control-paste.ts | 118 ++++ .../src/lib/linear-board-drag-payload.test.ts | 88 +++ .../src/lib/linear-board-drag-payload.ts | 46 ++ .../lib/linear-issue-context-snapshot.test.ts | 25 +- .../src/lib/linear-issue-context-snapshot.ts | 73 +- .../lib/markdown-document-templates.test.ts | 14 + .../src/lib/markdown-document-templates.ts | 40 +- .../src/lib/markdown-review-notes.test.ts | 78 ++- src/renderer/src/lib/markdown-review-notes.ts | 136 +++- .../lib/new-workspace-project-options.test.ts | 51 +- .../src/lib/new-workspace-project-options.ts | 13 + src/renderer/src/lib/paste-payload-corpus.ts | 2 + .../src/lib/paste-payload-metadata.test.ts | 71 ++ .../src/lib/paste-payload-metadata.ts | 155 +++++ .../src/lib/primary-selection-paste.test.ts | 351 ++++++++++ .../src/lib/primary-selection-paste.ts | 208 +++++- .../src/lib/primary-selection.test.ts | 2 +- src/renderer/src/lib/primary-selection.ts | 9 +- src/renderer/src/lib/repo-search.test.ts | 49 +- src/renderer/src/lib/repo-search.ts | 15 +- src/renderer/src/lib/running-agent-targets.ts | 7 +- .../src/lib/script-textarea-rows.test.ts | 44 ++ src/renderer/src/lib/script-textarea-rows.ts | 33 + .../src/lib/session-write-subscriber.ts | 18 +- .../src/lib/simulator-palette-search.test.ts | 29 +- .../src/lib/simulator-palette-search.ts | 16 +- .../lib/source-control-remote-error.test.ts | 41 ++ .../src/lib/source-control-remote-error.ts | 273 ++++++++ src/renderer/src/lib/sparse-paths.ts | 58 +- .../src/lib/sparse-preset-draft.test.ts | 17 +- src/renderer/src/lib/sparse-preset-draft.ts | 27 +- .../terminal-github-pr-link-detector.test.ts | 28 +- .../lib/terminal-github-pr-link-detector.ts | 83 ++- .../lib/terminal-quick-command-search.test.ts | 59 +- .../src/lib/terminal-quick-command-search.ts | 52 +- src/renderer/src/lib/terminal-theme.test.ts | 13 +- .../src/lib/terminal-title-contrast.ts | 45 +- .../src/lib/text-control-paste-diagnostics.ts | 152 ++++ .../src/lib/text-control-paste-model.ts | 55 ++ .../lib/text-control-paste-ownership.test.ts | 133 ++++ .../src/lib/text-control-paste-ownership.ts | 121 ++++ .../src/lib/text-control-paste.test.ts | 268 +++++++ src/renderer/src/lib/text-control-paste.ts | 319 +++++++++ .../lib/work-item-link-query-bounds.test.ts | 21 + .../src/lib/work-item-link-query-bounds.ts | 10 + .../src/lib/workspace-file-drag.test.ts | 119 ++++ src/renderer/src/lib/workspace-file-drag.ts | 201 +++++- .../lib/worktree-palette-comment-snippet.ts | 33 + .../worktree-palette-create-action.test.ts | 15 + .../src/lib/worktree-palette-create-action.ts | 8 + .../src/lib/worktree-palette-query-bounds.ts | 10 + .../src/lib/worktree-palette-search.test.ts | 42 ++ .../src/lib/worktree-palette-search.ts | 44 +- .../runtime/mobile-markdown-bridge.test.ts | 61 ++ .../src/runtime/mobile-markdown-bridge.ts | 8 +- .../src/runtime/runtime-file-client.test.ts | 42 ++ .../src/runtime/runtime-file-client.ts | 7 + .../runtime-file-search-bounds.test.ts | 36 + .../src/runtime/runtime-file-search-bounds.ts | 61 ++ .../src/runtime/runtime-jira-client.test.ts | 52 ++ .../src/runtime/runtime-jira-client.ts | 7 + .../src/runtime/runtime-linear-client.test.ts | 18 + .../src/runtime/runtime-linear-client.ts | 7 + .../runtime-provider-search-bounds.test.ts | 18 + .../runtime/runtime-provider-search-bounds.ts | 35 + .../src/runtime/runtime-repo-client.test.ts | 54 ++ .../src/runtime/runtime-repo-client.ts | 7 + .../runtime-repo-search-bounds.test.ts | 13 + .../src/runtime/runtime-repo-search-bounds.ts | 32 + .../runtime-terminal-inspection.test.ts | 117 ++++ .../runtime/runtime-terminal-inspection.ts | 33 + .../slices/agent-generated-tab-title.test.ts | 23 + src/renderer/src/store/slices/editor.ts | 248 +------ .../github-work-items-query-bounds.test.ts | 25 + .../slices/github-work-items-query-bounds.ts | 4 + src/renderer/src/store/slices/github.test.ts | 49 ++ src/renderer/src/store/slices/github.ts | 22 + .../src/store/slices/ssh-target-cleanup.ts | 3 +- src/renderer/src/store/slices/terminals.ts | 21 +- src/renderer/src/web/web-preload-api.test.ts | 210 ++++++ src/renderer/src/web/web-preload-api.ts | 49 +- .../agent-decorative-title-signature.test.ts | 52 ++ .../agent-decorative-title-signature.ts | 64 ++ src/shared/agent-detection.test.ts | 53 ++ src/shared/agent-detection.ts | 46 +- src/shared/agent-hook-listener.test.ts | 133 ++++ src/shared/agent-hook-listener.ts | 82 ++- src/shared/agent-process-recognition.ts | 7 +- src/shared/agent-status-types.test.ts | 55 +- src/shared/agent-status-types.ts | 118 +++- src/shared/agent-tab-title.test.ts | 42 +- src/shared/agent-tab-title.ts | 47 +- src/shared/automation-schedules.test.ts | 30 +- src/shared/automation-schedules.ts | 45 +- src/shared/clipboard-image.test.ts | 45 ++ src/shared/clipboard-image.ts | 38 + src/shared/clipboard-text.test.ts | 155 +++++ src/shared/clipboard-text.ts | 189 +++++ src/shared/codex-auth-errors.test.ts | 40 ++ src/shared/codex-auth-errors.ts | 45 +- src/shared/command-token-scanner.test.ts | 55 ++ src/shared/command-token-scanner.ts | 83 +++ src/shared/commit-message-agent-output.ts | 269 ++++++++ src/shared/commit-message-agent-spec.test.ts | 57 +- src/shared/commit-message-agent-spec.ts | 184 +++-- src/shared/commit-message-generation.test.ts | 21 +- src/shared/commit-message-generation.ts | 5 +- src/shared/commit-message-prompt.test.ts | 63 +- src/shared/commit-message-prompt.ts | 93 +-- src/shared/git-clone-failure-message.test.ts | 20 +- src/shared/git-clone-failure-message.ts | 37 +- src/shared/git-effective-upstream.ts | 52 +- src/shared/git-fork-sync.test.ts | 29 +- src/shared/git-fork-sync.ts | 37 +- src/shared/git-publish-target-status.ts | 16 +- src/shared/git-remote-error.test.ts | 36 +- src/shared/git-remote-error.ts | 43 +- src/shared/git-rev-list-output.test.ts | 54 ++ src/shared/git-rev-list-output.ts | 39 ++ src/shared/git-upstream-status.test.ts | 55 ++ src/shared/git-upstream-status.ts | 38 +- src/shared/github-project-ref-input.test.ts | 45 ++ src/shared/github-project-ref-input.ts | 19 + .../github-work-items-query-bounds.test.ts | 28 + src/shared/github-work-items-query-bounds.ts | 10 + src/shared/mobile-markdown-document.test.ts | 48 ++ src/shared/mobile-markdown-document.ts | 8 +- src/shared/native-file-drop.test.ts | 224 +++++- src/shared/native-file-drop.ts | 191 ++++- src/shared/osc-title-extraction.ts | 125 ++++ src/shared/paste-payload-corpus.ts | 109 +++ src/shared/pi-agent-kind.test.ts | 1 + src/shared/pi-agent-kind.ts | 3 +- .../process-output-field-scanner.test.ts | 49 ++ src/shared/process-output-field-scanner.ts | 72 ++ src/shared/pull-request-generation.test.ts | 28 +- src/shared/pull-request-generation.ts | 58 +- src/shared/ssh-types.ts | 4 + src/shared/terminal-input.test.ts | 134 ++++ src/shared/terminal-input.ts | 109 +++ src/shared/workspace-name-text-scanner.ts | 129 ++++ src/shared/workspace-name.test.ts | 36 +- src/shared/workspace-name.ts | 34 +- src/shared/wsl-login-shell-command.test.ts | 2 + src/shared/wsl-login-shell-command.ts | 6 +- tests/e2e/app-menu-paste-ownership.spec.ts | 156 +++++ .../editable-context-paste-ownership.spec.ts | 98 +++ tests/e2e/helpers/terminal-pty-write-spy.ts | 103 +++ tests/e2e/tab-rename-paste-ownership.spec.ts | 83 +++ ...erminal-large-paste-responsiveness.spec.ts | 164 +++++ tests/e2e/terminal-paste-ownership.spec.ts | 311 +++++++++ ...erminal-split-pane-paste-ownership.spec.ts | 197 ++++++ ...inal-windows-shell-paste-ownership.spec.ts | 468 +++++++++++++ 592 files changed, 33301 insertions(+), 3735 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-graph-parsers.test.ts create mode 100644 src/main/ai-vault/session-scanner-grok-parser.test.ts create mode 100644 src/main/ai-vault/session-scanner-text-normalization.ts create mode 100644 src/main/ai-vault/session-scanner-values.test.ts create mode 100644 src/main/browser/browser-text-insertion.test.ts create mode 100644 src/main/browser/browser-text-insertion.ts create mode 100644 src/main/computer/computer-clipboard-paste-validation.ts create mode 100644 src/main/computer/computer-sidecar-paste-validation.ts create mode 100644 src/main/computer/desktop-script-provider-paste-validation.test.ts create mode 100644 src/main/computer/macos-native-provider-paste-validation.test.ts create mode 100644 src/main/daemon/osc7-uri-extraction.test.ts create mode 100644 src/main/daemon/osc7-uri-extraction.ts create mode 100644 src/main/gitlab/glab-api-response.ts create mode 100644 src/main/gitlab/glab-known-hosts.ts create mode 100644 src/main/headless-automation-dispatcher-source-boundary.test.ts create mode 100644 src/main/ipc/ui.test.ts create mode 100644 src/main/linear/issue-context-errors.test.ts create mode 100644 src/main/runtime/rpc/methods/browser-text-rpc-methods.ts create mode 100644 src/main/runtime/rpc/rpc-clipboard-text-validation.ts create mode 100644 src/main/ssh/ssh-remote-platform-detection.test.ts create mode 100644 src/relay/fs-handler-install-rg.test.ts create mode 100644 src/renderer/src/components/automations/hermes-cron-output-parse.test.ts create mode 100644 src/renderer/src/components/automations/hermes-cron-output-parse.ts create mode 100644 src/renderer/src/components/dictation/dictation-insertion-target.test.ts create mode 100644 src/renderer/src/components/editor/combined-diff-commit-message.test.ts create mode 100644 src/renderer/src/components/editor/combined-diff-commit-message.ts create mode 100644 src/renderer/src/components/editor/details-markdown-html.test.ts create mode 100644 src/renderer/src/components/editor/diff-line-stats.test.ts create mode 100644 src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts create mode 100644 src/renderer/src/components/editor/ipynb-code-cell-lines.ts create mode 100644 src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts create mode 100644 src/renderer/src/components/editor/monaco-auto-height.test.ts create mode 100644 src/renderer/src/components/editor/monaco-auto-height.ts create mode 100644 src/renderer/src/components/editor/monaco-large-text-paste.test.ts create mode 100644 src/renderer/src/components/editor/monaco-large-text-paste.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-large-text-paste.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-paste-handler.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-search.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-slash-command-filter.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-slash-command-filter.ts create mode 100644 src/renderer/src/components/emulator-pane/emulator-keyboard-paste.test.ts create mode 100644 src/renderer/src/components/emulator-pane/emulator-keyboard-paste.ts create mode 100644 src/renderer/src/components/emulator-pane/emulator-screen-aria-label.ts create mode 100644 src/renderer/src/components/github-project/github-project-picker-filter.test.ts create mode 100644 src/renderer/src/components/github-project/github-project-picker-filter.ts create mode 100644 src/renderer/src/components/github/PRFilterPickers.test.ts create mode 100644 src/renderer/src/components/github/github-markdown-image-url.test.ts create mode 100644 src/renderer/src/components/github/github-markdown-image-url.ts create mode 100644 src/renderer/src/components/github/github-mention-option-filter.test.ts create mode 100644 src/renderer/src/components/github/github-mention-option-filter.ts create mode 100644 src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts create mode 100644 src/renderer/src/components/github/github-pr-reviewer-candidate-filter.ts create mode 100644 src/renderer/src/components/github/github-work-item-option-filter-bounds.ts create mode 100644 src/renderer/src/components/github/pr-comment-code-context.test.ts create mode 100644 src/renderer/src/components/github/pr-comment-code-context.ts create mode 100644 src/renderer/src/components/jira-create-adf.test.ts create mode 100644 src/renderer/src/components/jira-create-adf.ts create mode 100644 src/renderer/src/components/jira-project-picker-filter.test.ts create mode 100644 src/renderer/src/components/jira-project-picker-filter.ts create mode 100644 src/renderer/src/components/linear-project-search-query.test.ts create mode 100644 src/renderer/src/components/linear-project-search-query.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-commit-message-rows.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-commit-message-rows.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-file-filter.ts create mode 100644 src/renderer/src/components/settings/ShortcutFilterRail.test.ts create mode 100644 src/renderer/src/components/settings/agent-default-env-draft.test.ts create mode 100644 src/renderer/src/components/settings/agent-default-env-draft.ts create mode 100644 src/renderer/src/components/settings/settings-form-option-filter.test.ts create mode 100644 src/renderer/src/components/settings/settings-form-option-filter.ts create mode 100644 src/renderer/src/components/settings/settings-search.test.ts create mode 100644 src/renderer/src/components/settings/worktree-symlink-path-filter.test.ts create mode 100644 src/renderer/src/components/settings/worktree-symlink-path-filter.ts create mode 100644 src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx create mode 100644 src/renderer/src/components/sidebar/workspace-status-drag-data.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-file-matches.ts create mode 100644 src/renderer/src/components/terminal-pane/command-code-prompt-text.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-eager-buffer-clamp.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-internal-rejection-message.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-shell.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-shell.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-target.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-upload-report.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-write-failure.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-write-failure.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-coordinator.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-diagnostics.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-errors.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-executor.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-limits.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-model.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-multiline-policy.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-operation-timeout.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-payload-metadata.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-payload-metadata.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-runtime.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-ssh-platform.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts create mode 100644 src/renderer/src/components/ui/team-multi-combobox.test.ts create mode 100644 src/renderer/src/hooks/composer-drop-owner.test.ts create mode 100644 src/renderer/src/hooks/composer-drop-owner.ts create mode 100644 src/renderer/src/hooks/composer-drop-upload-result.test.ts create mode 100644 src/renderer/src/hooks/composer-drop-upload-result.ts create mode 100644 src/renderer/src/hooks/useAppMenuPaste.test.tsx create mode 100644 src/renderer/src/hooks/useAppMenuPaste.ts create mode 100644 src/renderer/src/hooks/useLargeTextControlPaste.ts create mode 100644 src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx create mode 100644 src/renderer/src/lib/agent-draft-paste-content.ts create mode 100644 src/renderer/src/lib/agent-draft-readiness.ts create mode 100644 src/renderer/src/lib/app-menu-paste.test.ts create mode 100644 src/renderer/src/lib/app-menu-paste.ts create mode 100644 src/renderer/src/lib/comment-body-line-count.test.ts create mode 100644 src/renderer/src/lib/comment-body-line-count.ts create mode 100644 src/renderer/src/lib/comment-body-submit-state.test.ts create mode 100644 src/renderer/src/lib/comment-body-submit-state.ts create mode 100644 src/renderer/src/lib/find-query-bounds.test.ts create mode 100644 src/renderer/src/lib/find-query-bounds.ts create mode 100644 src/renderer/src/lib/large-text-control-paste.test.ts create mode 100644 src/renderer/src/lib/large-text-control-paste.ts create mode 100644 src/renderer/src/lib/linear-board-drag-payload.test.ts create mode 100644 src/renderer/src/lib/linear-board-drag-payload.ts create mode 100644 src/renderer/src/lib/paste-payload-corpus.ts create mode 100644 src/renderer/src/lib/paste-payload-metadata.test.ts create mode 100644 src/renderer/src/lib/paste-payload-metadata.ts create mode 100644 src/renderer/src/lib/primary-selection-paste.test.ts create mode 100644 src/renderer/src/lib/script-textarea-rows.test.ts create mode 100644 src/renderer/src/lib/script-textarea-rows.ts create mode 100644 src/renderer/src/lib/source-control-remote-error.test.ts create mode 100644 src/renderer/src/lib/source-control-remote-error.ts create mode 100644 src/renderer/src/lib/text-control-paste-diagnostics.ts create mode 100644 src/renderer/src/lib/text-control-paste-model.ts create mode 100644 src/renderer/src/lib/text-control-paste-ownership.test.ts create mode 100644 src/renderer/src/lib/text-control-paste-ownership.ts create mode 100644 src/renderer/src/lib/text-control-paste.test.ts create mode 100644 src/renderer/src/lib/text-control-paste.ts create mode 100644 src/renderer/src/lib/work-item-link-query-bounds.test.ts create mode 100644 src/renderer/src/lib/work-item-link-query-bounds.ts create mode 100644 src/renderer/src/lib/workspace-file-drag.test.ts create mode 100644 src/renderer/src/lib/worktree-palette-comment-snippet.ts create mode 100644 src/renderer/src/lib/worktree-palette-query-bounds.ts create mode 100644 src/renderer/src/runtime/runtime-file-search-bounds.test.ts create mode 100644 src/renderer/src/runtime/runtime-file-search-bounds.ts create mode 100644 src/renderer/src/runtime/runtime-jira-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-provider-search-bounds.test.ts create mode 100644 src/renderer/src/runtime/runtime-provider-search-bounds.ts create mode 100644 src/renderer/src/runtime/runtime-repo-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-repo-search-bounds.test.ts create mode 100644 src/renderer/src/runtime/runtime-repo-search-bounds.ts create mode 100644 src/renderer/src/store/slices/github-work-items-query-bounds.test.ts create mode 100644 src/renderer/src/store/slices/github-work-items-query-bounds.ts create mode 100644 src/shared/agent-decorative-title-signature.test.ts create mode 100644 src/shared/agent-decorative-title-signature.ts create mode 100644 src/shared/agent-detection.test.ts create mode 100644 src/shared/clipboard-image.test.ts create mode 100644 src/shared/clipboard-image.ts create mode 100644 src/shared/clipboard-text.test.ts create mode 100644 src/shared/clipboard-text.ts create mode 100644 src/shared/codex-auth-errors.test.ts create mode 100644 src/shared/command-token-scanner.test.ts create mode 100644 src/shared/command-token-scanner.ts create mode 100644 src/shared/commit-message-agent-output.ts create mode 100644 src/shared/git-rev-list-output.test.ts create mode 100644 src/shared/git-rev-list-output.ts create mode 100644 src/shared/git-upstream-status.test.ts create mode 100644 src/shared/github-project-ref-input.test.ts create mode 100644 src/shared/github-project-ref-input.ts create mode 100644 src/shared/github-work-items-query-bounds.test.ts create mode 100644 src/shared/github-work-items-query-bounds.ts create mode 100644 src/shared/mobile-markdown-document.test.ts create mode 100644 src/shared/osc-title-extraction.ts create mode 100644 src/shared/paste-payload-corpus.ts create mode 100644 src/shared/process-output-field-scanner.test.ts create mode 100644 src/shared/process-output-field-scanner.ts create mode 100644 src/shared/terminal-input.test.ts create mode 100644 src/shared/terminal-input.ts create mode 100644 src/shared/workspace-name-text-scanner.ts create mode 100644 tests/e2e/app-menu-paste-ownership.spec.ts create mode 100644 tests/e2e/editable-context-paste-ownership.spec.ts create mode 100644 tests/e2e/helpers/terminal-pty-write-spy.ts create mode 100644 tests/e2e/tab-rename-paste-ownership.spec.ts create mode 100644 tests/e2e/terminal-large-paste-responsiveness.spec.ts create mode 100644 tests/e2e/terminal-paste-ownership.spec.ts create mode 100644 tests/e2e/terminal-split-pane-paste-ownership.spec.ts create mode 100644 tests/e2e/terminal-windows-shell-paste-ownership.spec.ts diff --git a/config/scripts/locale-ko-key-overrides.mjs b/config/scripts/locale-ko-key-overrides.mjs index 8a2e8219e2f..e488f471fd0 100644 --- a/config/scripts/locale-ko-key-overrides.mjs +++ b/config/scripts/locale-ko-key-overrides.mjs @@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url' // Korean key-specific overrides (reviewed in full UI context: product names, git terms, and // labels MT mistranslated). Stored as a JSON data file — too many entries to inline under the // .mjs max-lines limit — and loaded here so the catalog scripts keep a single ko key-override source. -const jsonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'locale-ko-key-overrides.json') +const jsonPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'locale-ko-key-overrides.json' +) let parsed try { diff --git a/config/scripts/locale-ko-value-overrides.mjs b/config/scripts/locale-ko-value-overrides.mjs index f5b8b25b3e8..1d128a67897 100644 --- a/config/scripts/locale-ko-value-overrides.mjs +++ b/config/scripts/locale-ko-value-overrides.mjs @@ -234,43 +234,44 @@ export const KO_VALUE_OVERRIDES = { '이 워크스페이스의 최근 변경사항을 리뷰하세요. 정확성 위험, UX 회귀, 누락된 테스트 및 후속 작업에 중점을 둡니다. 보고서를 짧고 실행 가능하게 유지하세요.', 'Check for stuck work, stale generated files, failing validation, and anything that needs human attention. Report only actionable issues.': '작업 중단, 오래 생성된 파일, 유효성 검사 실패 및 사람의 주의가 필요한 모든 사항을 확인하세요. 실행 가능한 이슈만 보고하세요.', - 'Pipeline': '파이프라인', - 'Jobs': '작업', - 'Completed': '완료됨', + Pipeline: '파이프라인', + Jobs: '작업', + Completed: '완료됨', 'Posting…': '게시 중…', - 'thread': '스레드', + thread: '스레드', 'Close issue': '이슈 닫기', 'Close as completed': '완료로 닫기', - 'Checks': '체크', + Checks: '체크', 'Refresh checks': '체크 새로고침', 'Check rerun requested': '체크 재실행이 요청되었습니다', - 'Pull': '풀', - 'Sync': '동기화', + Pull: '풀', + Sync: '동기화', 'Publish Branch': '브랜치 게시', 'Stage at least one file to commit': '커밋할 파일을 하나 이상 스테이지하세요', 'Commit staged changes': '스테이지된 변경 사항 커밋', 'No commits yet': '아직 커밋이 없습니다', - 'Paste': '붙여넣기', + Paste: '붙여넣기', 'Next match': '다음 일치 항목', 'Previous match': '이전 일치 항목', - 'Key': '키', - 'Prompt': '프롬프트', - 'Label': '레이블', - 'Action': '작업', - 'Views': '뷰', - 'Jira JQL, e.g. project = ABC AND statusCategory != Done': 'Jira JQL, 예: project = ABC AND statusCategory != Done', + Key: '키', + Prompt: '프롬프트', + Label: '레이블', + Action: '작업', + Views: '뷰', + 'Jira JQL, e.g. project = ABC AND statusCategory != Done': + 'Jira JQL, 예: project = ABC AND statusCategory != Done', 'npm run dev': 'npm run dev', '/goal': '/goal', - 'Grab': '가져오기', - 'Change': '변경', - 'Intent': '의도', + Grab: '가져오기', + Change: '변경', + Intent: '의도', 'Take back': '제어권 가져오기', - 'Android': 'Android', - 'Network': '네트워크', + Android: 'Android', + Network: '네트워크', 'Available on': '지원 플랫폼', 'Git Bash': 'Git Bash', - 'Set': '설정', - 'Test': '테스트', + Set: '설정', + Test: '테스트', 'Azure DevOps': 'Azure DevOps', 'Review providers': '리뷰 제공자', 'Task providers': '작업 제공자', @@ -278,22 +279,22 @@ export const KO_VALUE_OVERRIDES = { 'Refresh PR checks': 'PR 체크 새로고침', 'Run on': '실행 위치', 'Clone project': '프로젝트 클론', - 'Smart': '스마트', + Smart: '스마트', 'Workspace name': '워크스페이스 이름', '• inferred pricing': '• 추정 가격', 'Run context': '실행 컨텍스트', - 'Dirty': '변경 있음', - 'Unread': '읽지 않음', - 'Home': '홈', - 'Local': '로컬', + Dirty: '변경 있음', + Unread: '읽지 않음', + Home: '홈', + Local: '로컬', 'Reveal file': '파일 표시', 'new markdown': '새 Markdown', 'new shell': '새 shell', 'trash worktree': '워크트리 휴지통으로 이동', - 'AI': 'AI', - 'Server': '서버', - 'Web': '웹', - 'Code': '코드', - 'Folder': '폴더', + AI: 'AI', + Server: '서버', + Web: '웹', + Code: '코드', + Folder: '폴더', 'Hermes automation created.': 'Hermes 자동화가 생성되었습니다.' } diff --git a/src/main/ai-vault/session-scanner-graph-parsers.test.ts b/src/main/ai-vault/session-scanner-graph-parsers.test.ts new file mode 100644 index 00000000000..22809eca864 --- /dev/null +++ b/src/main/ai-vault/session-scanner-graph-parsers.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' +import { rovoPartsText } from './session-scanner-graph-parsers' + +describe('AI Vault graph session parsers', () => { + it('folds large Rovo prompt parts without joining the selected text', () => { + const joinSpy = vi.spyOn(Array.prototype, 'join') + const result = rovoPartsText( + [ + { part_kind: 'tool-output', content: 'ignored' }, + { part_kind: 'user-prompt', content: 'Rovo prompt '.repeat(80) }, + { part_kind: 'text', text: 'tail' } + ], + 'user' + ) + const joinCalls = joinSpy.mock.calls.length + + expect(joinCalls).toBe(0) + expect(result?.startsWith('Rovo prompt Rovo prompt')).toBe(true) + expect(result?.endsWith('...')).toBe(true) + }) +}) diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index a4eafced6cc..612e5690146 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -128,7 +128,7 @@ export function rovoRoleFromKind(value: unknown): 'user' | 'assistant' | null { } export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): string | null { - const texts: string[] = [] + const textParts: string[] = [] for (const part of parts) { const record = asRecord(part) if (!record) { @@ -141,12 +141,17 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str if (role === 'assistant' && kind !== 'text') { continue } - const text = extractString(record.content) ?? extractString(record.text) - if (text) { - texts.push(text) + const text = + typeof record.content === 'string' + ? record.content + : typeof record.text === 'string' + ? record.text + : null + if (text !== null) { + textParts.push(text) } } - return normalizeTitleText(texts.join(' ')) + return extractContentText(textParts) } export async function parseMessageGraphSessionFile( diff --git a/src/main/ai-vault/session-scanner-grok-parser.test.ts b/src/main/ai-vault/session-scanner-grok-parser.test.ts new file mode 100644 index 00000000000..75c2d7575a6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-grok-parser.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' +import { extractGrokContentText } from './session-scanner-grok-parser' + +describe('AI Vault Grok session parser', () => { + it('extracts bounded user_query text without trimming the full body', () => { + const trimSpy = vi.spyOn(String.prototype, 'trim') + const result = extractGrokContentText( + `context\n${'Grok prompt '.repeat(400)}` + ) + const trimCalls = trimSpy.mock.calls.length + + expect(trimCalls).toBe(0) + expect(result?.startsWith('Grok prompt Grok prompt')).toBe(true) + expect(result?.endsWith('...')).toBe(true) + expect(result).not.toContain('USER_QUERY') + }) + + it('folds Grok array content without joining all text parts', () => { + const joinSpy = vi.spyOn(Array.prototype, 'join') + const result = extractGrokContentText([ + { type: 'text', text: 'Grok array '.repeat(80) }, + { type: 'text', text: 'tail' } + ]) + const joinCalls = joinSpy.mock.calls.length + + expect(joinCalls).toBe(0) + expect(result?.startsWith('Grok array Grok array')).toBe(true) + expect(result?.endsWith('...')).toBe(true) + }) +}) diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index 53dca524860..380f865ac7a 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -13,12 +13,16 @@ import { } from './session-scanner-accumulator' import { asRecord, + extractPreviewContentText, extractString, + normalizePreviewText, normalizeTitleText, numberValue, parseJsonObject } from './session-scanner-values' +const GROK_USER_QUERY_PREVIEW_SCAN_LIMIT = 4096 + export async function parseGrokSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform @@ -79,32 +83,52 @@ async function consumeGrokChatHistory( } } -function extractGrokContentText(value: unknown): string | null { - const text = extractGrokRawContentText(value) - if (!text) { - return null +export function extractGrokContentText(value: unknown): string | null { + if (typeof value === 'string') { + return extractGrokStringContentText(value) } - return text.match(/\s*([\s\S]*?)\s*<\/user_query>/i)?.[1]?.trim() || text + return extractPreviewContentText(value) } -function extractGrokRawContentText(value: unknown): string | null { - if (typeof value === 'string') { - return extractString(value) +function extractGrokStringContentText(text: string): string | null { + const bounds = grokUserQueryEnvelopeBounds(text) + if (!bounds) { + return normalizePreviewText(text) } - if (!Array.isArray(value)) { + + const boundedEnd = Math.min(bounds.end, bounds.start + GROK_USER_QUERY_PREVIEW_SCAN_LIMIT) + return normalizePreviewText(text.slice(bounds.start, boundedEnd)) ?? normalizePreviewText(text) +} + +function grokUserQueryEnvelopeBounds(text: string): { start: number; end: number } | null { + const opener = '' + const startIndex = indexOfAsciiIgnoreCase(text, opener, 0) + if (startIndex === -1) { return null } - const parts: string[] = [] - for (const item of value) { - if (typeof item === 'string') { - parts.push(item) - continue + const bodyStartIndex = startIndex + opener.length + const endIndex = indexOfAsciiIgnoreCase(text, '', bodyStartIndex) + if (endIndex === -1) { + return null + } + return { start: bodyStartIndex, end: endIndex } +} + +function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number { + const lastStart = value.length - search.length + for (let index = Math.max(0, fromIndex); index <= lastStart; index++) { + let matches = true + for (let offset = 0; offset < search.length; offset++) { + const code = value.charCodeAt(index + offset) + const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code + if (normalizedCode !== search.charCodeAt(offset)) { + matches = false + break + } } - const record = asRecord(item) - const text = extractString(record?.text) || extractString(record?.content) - if (text) { - parts.push(text) + if (matches) { + return index } } - return extractString(parts.join(' ')) + return -1 } diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts new file mode 100644 index 00000000000..8bd58ad6407 --- /dev/null +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -0,0 +1,306 @@ +const SESSION_TITLE_TEXT_LIMIT = 96 +const SESSION_PREVIEW_TEXT_LIMIT = 220 +const ELLIPSIS = '...' +const HIDDEN_BLOCK_CLOSE_SCAN_LIMIT = 256 * 1024 +const HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT = 512 +const FIELD_BLANK_SCAN_LIMIT = 1024 + +const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions for' +const XML_INSTRUCTIONS_PREFIX = '' + +const HIDDEN_TEXT_BLOCKS = [ + { name: 'system-reminder', closeTag: '' }, + { name: 'codex_internal_context', closeTag: '' }, + { name: 'goal_context', closeTag: '' } +] as const + +type TextBuilder = { + readonly limit: number + text: string + pendingSpace: boolean + truncated: boolean +} + +export function extractMessageText(value: unknown): string | null { + const message = objectRecord(value) + return message ? extractContentText(message.content) : null +} + +export function extractContentText(value: unknown): string | null { + return normalizeContentText(value, SESSION_TITLE_TEXT_LIMIT) +} + +export function normalizeTitleText(value: string): string | null { + return finalizeNormalizedText(normalizeStringText(value, SESSION_TITLE_TEXT_LIMIT)) +} + +export function extractPreviewContentText(value: unknown): string | null { + return normalizeContentText(value, SESSION_PREVIEW_TEXT_LIMIT) +} + +export function normalizePreviewText(value: string): string | null { + return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT)) +} + +function normalizeContentText(value: unknown, limit: number): string | null { + if (typeof value === 'string') { + return finalizeNormalizedText(normalizeStringText(value, limit)) + } + if (!Array.isArray(value)) { + return null + } + + const builder = createTextBuilder(limit) + for (const item of value) { + const text = contentItemText(item) + if (text === null) { + continue + } + appendInterPartSpace(builder) + appendNormalizedString(builder, text) + if (builder.truncated) { + break + } + } + + return finalizeNormalizedText(builder) +} + +function normalizeStringText(value: string, limit: number): TextBuilder { + const builder = createTextBuilder(limit) + appendNormalizedString(builder, value) + return builder +} + +function createTextBuilder(limit: number): TextBuilder { + return { limit, text: '', pendingSpace: false, truncated: false } +} + +function contentItemText(item: unknown): string | null { + if (typeof item === 'string') { + return item + } + + const record = objectRecord(item) + if (!record) { + return null + } + + return nonBlankString(record.text) ?? nonBlankString(record.content) +} + +function nonBlankString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + return hasNonWhitespace(value, FIELD_BLANK_SCAN_LIMIT) ? value : null +} + +function hasNonWhitespace(value: string, maxScanLength: number): boolean { + const scanLimit = Math.min(value.length, maxScanLength) + for (let index = 0; index < scanLimit; index += 1) { + if (!isWhitespaceCode(value.charCodeAt(index))) { + return true + } + } + return value.length > scanLimit +} + +function appendInterPartSpace(builder: TextBuilder): void { + if (builder.text.length > 0) { + builder.pendingSpace = true + } +} + +function appendNormalizedString(builder: TextBuilder, value: string): void { + let index = 0 + while (index < value.length && !builder.truncated) { + const hiddenBlockEnd = hiddenTextBlockEnd(value, index) + if (hiddenBlockEnd !== null) { + if (builder.text.length > 0) { + builder.pendingSpace = true + } + index = hiddenBlockEnd + continue + } + + const code = value.charCodeAt(index) + if (isWhitespaceCode(code)) { + if (builder.text.length > 0) { + builder.pendingSpace = true + } + index += 1 + continue + } + + if (builder.pendingSpace) { + appendVisibleText(builder, ' ') + builder.pendingSpace = false + if (builder.truncated) { + break + } + } + + const charLength = codePointLength(value, index) + appendVisibleText(builder, value.slice(index, index + charLength)) + index += charLength + } +} + +function appendVisibleText(builder: TextBuilder, value: string): void { + builder.text += value + builder.truncated = builder.text.length > builder.limit +} + +function hiddenTextBlockEnd(value: string, index: number): number | null { + if (value.charCodeAt(index) !== 60) { + return null + } + + for (const block of HIDDEN_TEXT_BLOCKS) { + const nameStart = index + 1 + if (!startsWithIgnoreCase(value, block.name, nameStart)) { + continue + } + + const afterName = nameStart + block.name.length + if (!isTagBoundary(value.charCodeAt(afterName))) { + continue + } + + const openEnd = tagEndIndex(value, afterName) + if (openEnd === null) { + // Why: malformed hidden context should not leak into AI Vault titles/previews. + return value.length + } + + const closeStart = indexOfIgnoreCase( + value, + block.closeTag, + openEnd + 1, + openEnd + 1 + HIDDEN_BLOCK_CLOSE_SCAN_LIMIT + ) + return closeStart === -1 ? value.length : closeStart + block.closeTag.length + } + + return null +} + +function tagEndIndex(value: string, fromIndex: number): number | null { + const scanEnd = Math.min(value.length, fromIndex + HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT) + for (let index = fromIndex; index < scanEnd; index += 1) { + if (value.charCodeAt(index) === 62) { + return index + } + } + return null +} + +function indexOfIgnoreCase( + value: string, + search: string, + fromIndex: number, + endIndex: number +): number { + const lastStart = Math.min(value.length, endIndex, value.length - search.length + 1) + for (let index = fromIndex; index < lastStart; index += 1) { + if (startsWithIgnoreCase(value, search, index)) { + return index + } + } + return -1 +} + +function finalizeNormalizedText(builder: TextBuilder): string | null { + if (!builder.text) { + return null + } + if (isSuppressedContextPrefix(builder.text)) { + return null + } + return builder.truncated ? truncateWithEllipsis(builder.text, builder.limit) : builder.text +} + +function isSuppressedContextPrefix(value: string): boolean { + return ( + (startsWithIgnoreCase(value, AGENTS_INSTRUCTIONS_PREFIX, 0) && + isWordBoundary(value.charCodeAt(AGENTS_INSTRUCTIONS_PREFIX.length))) || + startsWithIgnoreCase(value, XML_INSTRUCTIONS_PREFIX, 0) + ) +} + +function truncateWithEllipsis(value: string, limit: number): string { + const end = Math.max(0, limit - ELLIPSIS.length) + const safeEnd = end > 0 && isHighSurrogate(value.charCodeAt(end - 1)) ? end - 1 : end + return `${value.slice(0, safeEnd)}${ELLIPSIS}` +} + +function objectRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function startsWithIgnoreCase(value: string, search: string, fromIndex: number): boolean { + if (fromIndex + search.length > value.length) { + return false + } + for (let index = 0; index < search.length; index += 1) { + if ( + toLowerAscii(value.charCodeAt(fromIndex + index)) !== toLowerAscii(search.charCodeAt(index)) + ) { + return false + } + } + return true +} + +function toLowerAscii(code: number): number { + return code >= 65 && code <= 90 ? code + 32 : code +} + +function isTagBoundary(code: number): boolean { + return Number.isNaN(code) || code === 62 || isWhitespaceCode(code) +} + +function isWordBoundary(code: number): boolean { + return Number.isNaN(code) || !isWordCode(code) +} + +function isWordCode(code: number): boolean { + return ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + code === 95 + ) +} + +function isWhitespaceCode(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +function codePointLength(value: string, index: number): number { + const code = value.charCodeAt(index) + return isHighSurrogate(code) && isLowSurrogate(value.charCodeAt(index + 1)) ? 2 : 1 +} + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff +} + +function isLowSurrogate(code: number): boolean { + return code >= 0xdc00 && code <= 0xdfff +} diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts new file mode 100644 index 00000000000..9c1de341d28 --- /dev/null +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { + extractPreviewContentText, + normalizePreviewText, + normalizeTitleText +} from './session-scanner-values' + +describe('AI Vault session scanner text values', () => { + it('normalizes compact title text without surfacing hidden context blocks', () => { + expect( + normalizeTitleText( + ' ignore me\n' + + 'keep going\tFix the picker ' + ) + ).toBe('Fix the picker') + expect(normalizeTitleText('# AGENTS.md instructions for /repo/app ')).toBeNull() + expect(normalizeTitleText('Use this repo guidance')).toBeNull() + }) + + it('folds large preview text directly without full-string replacement', () => { + const replaceSpy = vi.spyOn(String.prototype, 'replace') + const hiddenContext = `${'SECRET\n'.repeat(10_000)}` + const result = normalizePreviewText(`${hiddenContext}\nVisible preview ${'copy '.repeat(120)}`) + const replaceCalls = replaceSpy.mock.calls.length + + expect(replaceCalls).toBe(0) + expect(result?.startsWith('Visible preview copy copy')).toBe(true) + expect(result).not.toContain('SECRET') + expect(result?.endsWith('...')).toBe(true) + }) + + it('stops reading preview array items after the bounded display text is settled', () => { + const unreadItem = {} + Object.defineProperty(unreadItem, 'text', { + get() { + throw new Error('later preview items should not be read') + } + }) + + const result = extractPreviewContentText([ + { type: 'text', text: 'Visible preview '.repeat(30) }, + unreadItem + ]) + const normalizedPreview = Array.from({ length: 30 }, () => 'Visible preview').join(' ') + + expect(result).toBe(`${normalizedPreview.slice(0, 217)}...`) + }) + + it('keeps truncation from splitting surrogate pairs', () => { + const result = normalizePreviewText(`${'a'.repeat(216)}😀tail`) + + expect(result).toBe(`${'a'.repeat(216)}...`) + }) +}) diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 51e45aa1d24..8240a00f2b0 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -2,10 +2,6 @@ import { homedir } from 'os' import { basename, dirname, join } from 'path' import { readFile } from 'fs/promises' -const SESSION_PREVIEW_TEXT_LIMIT = 220 -const HIDDEN_USER_CONTEXT_BLOCK_PATTERN = - /<(?:codex_internal_context\b[^>]*|goal_context)>[\s\S]*?<\/(?:codex_internal_context|goal_context)>/gi - export function timestampMs(value: unknown): number { if (typeof value === 'string') { const parsed = Date.parse(value) @@ -57,6 +53,14 @@ export function extractModel(value: unknown): string | null { ) } +export { + extractContentText, + extractMessageText, + extractPreviewContentText, + normalizePreviewText, + normalizeTitleText +} from './session-scanner-text-normalization' + export function extractGitBranch(value: unknown): string | null { const git = asRecord(value) if (!git) { @@ -65,93 +69,6 @@ export function extractGitBranch(value: unknown): string | null { return extractString(git.branch) || extractString(git.current_branch) } -export function extractMessageText(value: unknown): string | null { - const message = asRecord(value) - if (!message) { - return null - } - return extractContentText(message.content) -} - -export function extractContentText(value: unknown): string | null { - if (typeof value === 'string') { - return normalizeTitleText(value) - } - if (!Array.isArray(value)) { - return null - } - const parts: string[] = [] - for (const item of value) { - if (typeof item === 'string') { - parts.push(item) - continue - } - const record = asRecord(item) - const text = extractString(record?.text) || extractString(record?.content) - if (text) { - parts.push(text) - } - } - return normalizeTitleText(parts.join(' ')) -} - -export function normalizeTitleText(value: string): string | null { - const withoutReminders = value - .replace(/[\s\S]*?<\/system-reminder>/gi, ' ') - .replace(HIDDEN_USER_CONTEXT_BLOCK_PATTERN, ' ') - .replace(/\s+/g, ' ') - .trim() - if (!withoutReminders) { - return null - } - if (/^# AGENTS\.md instructions for\b/i.test(withoutReminders)) { - return null - } - if (/^/i.test(withoutReminders)) { - return null - } - return withoutReminders.length > 96 ? `${withoutReminders.slice(0, 93)}...` : withoutReminders -} - -export function extractPreviewContentText(value: unknown): string | null { - if (typeof value === 'string') { - return normalizePreviewText(value) - } - if (!Array.isArray(value)) { - return null - } - const parts: string[] = [] - for (const item of value) { - if (typeof item === 'string') { - parts.push(item) - continue - } - const record = asRecord(item) - const text = extractString(record?.text) || extractString(record?.content) - if (text) { - parts.push(text) - } - } - return normalizePreviewText(parts.join(' ')) -} - -export function normalizePreviewText(value: string): string | null { - const normalized = value - .replace(/[\s\S]*?<\/system-reminder>/gi, ' ') - .replace(HIDDEN_USER_CONTEXT_BLOCK_PATTERN, ' ') - .replace(/\s+/g, ' ') - .trim() - if (!normalized) { - return null - } - if (/^# AGENTS\.md instructions for\b/i.test(normalized) || /^/i.test(normalized)) { - return null - } - return normalized.length > SESSION_PREVIEW_TEXT_LIMIT - ? `${normalized.slice(0, SESSION_PREVIEW_TEXT_LIMIT - 3)}...` - : normalized -} - export async function readJsonObjectIfExists( filePath: string ): Promise | null> { diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 86ca2a39a17..76aa5a00948 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -1,13 +1,14 @@ import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' let tempRoots: string[] = [] afterEach(async () => { + vi.restoreAllMocks() await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) tempRoots = [] }) @@ -669,6 +670,49 @@ describe('scanAiVaultSessions', () => { "cd '/tmp/kimi' && kimi --session 'session_kimi-session'" ) }) + + it('strips newline-heavy Grok user_query envelopes without regex matching', async () => { + const matchSpy = vi.spyOn(String.prototype, 'match') + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-grok-large-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionDir = join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'large-session') + const requestText = 'Grok large title\n'.repeat(300) + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, 'summary.json'), + JSON.stringify({ + info: { id: 'large-session', cwd: '/tmp/grok' }, + created_at: '2026-05-01T10:04:00.000Z' + }) + ) + await writeFile( + join(sessionDir, 'chat_history.jsonl'), + jsonLines([ + { + type: 'user', + content: `context\n${requestText}` + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin', + limit: 5 + }) + + expect(result.issues).toEqual([]) + expect(result.sessions[0]?.title).toContain('Grok large title') + expect(result.sessions[0]?.title).not.toContain('USER_QUERY') + const usedGrokWrapperMatch = matchSpy.mock.calls.some( + ([pattern]) => + pattern instanceof RegExp && + pattern.source.includes('') && + pattern.source.includes('[\\s\\S]') + ) + expect(usedGrokWrapperMatch).toBe(false) + }) }) describe('buildAiVaultResumeCommand', () => { diff --git a/src/main/automations/hermes-cron-output.test.ts b/src/main/automations/hermes-cron-output.test.ts index 4adeab33da3..9d78ca09f51 100644 --- a/src/main/automations/hermes-cron-output.test.ts +++ b/src/main/automations/hermes-cron-output.test.ts @@ -143,6 +143,48 @@ Run summary: monitor automation completed successfully. ) }) + it('builds large response previews without broad regex captures', async () => { + const home = await createHermesHome() + const outputDir = join(home, 'cron', 'output', 'job-1') + await mkdir(outputDir, { recursive: true }) + await writeFile( + join(outputDir, '2026-05-15_09-02-00.md'), + [ + '# Cron Job: Monitor automation', + '', + '## Response', + '', + '```', + 'hidden-token\n'.repeat(500), + '```', + '', + 'Visible response text '.repeat(500), + '' + ].join('\n'), + 'utf-8' + ) + + const { readHermesCronOutputRunsPage } = await loadReader() + const execSpy = vi.spyOn(RegExp.prototype, 'exec') + const replaceSpy = vi.spyOn(String.prototype, 'replace') + const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 }) + const usedBroadCapture = execSpy.mock.contexts.some( + (pattern) => pattern instanceof RegExp && pattern.source.includes('[\\s\\S]') + ) + const usedWhitespaceReplace = replaceSpy.mock.calls.some( + ([pattern]) => pattern instanceof RegExp && pattern.source === '\\s+' + ) + + expect(usedBroadCapture).toBe(false) + expect(usedWhitespaceReplace).toBe(false) + expect((page.runs[0] as { output_preview?: string }).output_preview).toContain( + 'Visible response text' + ) + expect((page.runs[0] as { output_preview?: string }).output_preview).not.toContain( + 'hidden-token' + ) + }) + it('does not hydrate referenced logs outside Hermes home', async () => { const home = await createHermesHome() const outputDir = join(home, 'cron', 'output', 'job-1') diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index 1b36eb3011e..f427db17085 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -16,6 +16,7 @@ const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 const FULL_SESSION_LOG_HEADING = '## Full session log' const REFERENCED_LOG_HEADING = '## Latest log file' +const RUN_PREVIEW_LIMIT = 180 const LATEST_LOG_PATH_PATTERN = /\bLatest log path:\s*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i @@ -107,18 +108,75 @@ function escapeSqlLike(value: string): string { return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') } -function cleanRunPreview(value: string): string | null { - const normalized = value - .replace(/```[\s\S]*?```/g, ' ') - .replace(/[#*_`>()]/g, ' ') - .replaceAll('[', ' ') - .replaceAll(']', ' ') - .replace(/\s+/g, ' ') - .trim() - if (!normalized) { +type ContentRange = { + start: number + end: number +} + +function cleanRunPreview(value: string, startIndex = 0, endIndex = value.length): string | null { + const normalized = foldRunPreviewText(value, startIndex, endIndex) + if (!normalized.text) { return null } - return normalized.length > 180 ? `${normalized.slice(0, 177)}...` : normalized + return normalized.truncated + ? `${normalized.text.slice(0, RUN_PREVIEW_LIMIT - 3)}...` + : normalized.text +} + +function foldRunPreviewText( + value: string, + startIndex: number, + endIndex: number +): { text: string; truncated: boolean } { + let text = '' + let pendingSpace = false + let index = Math.max(0, startIndex) + const end = Math.min(value.length, endIndex) + while (index < end && text.length <= RUN_PREVIEW_LIMIT) { + if (startsWithAt(value, '```', index)) { + if (text.length > 0) { + pendingSpace = true + } + index = skipFencedBlock(value, index, end) + continue + } + + const code = value.charCodeAt(index) + if (isPreviewSeparator(code)) { + if (text.length > 0) { + pendingSpace = true + } + index += 1 + continue + } + + if (pendingSpace) { + text += ' ' + pendingSpace = false + if (text.length > RUN_PREVIEW_LIMIT) { + break + } + } + text += value[index] + index += 1 + } + return { text, truncated: text.length > RUN_PREVIEW_LIMIT } +} + +function isPreviewSeparator(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 35 || + code === 40 || + code === 41 || + code === 42 || + code === 62 || + code === 91 || + code === 93 || + code === 95 || + code === 96 + ) } function parseHermesOutput(content: string): { @@ -127,18 +185,148 @@ function parseHermesOutput(content: string): { outputContent: string error: string | null } { - const failed = /^#\s+Cron Job:.*\(FAILED\)/m.test(content) || /^##\s+Error\b/m.test(content) - const errorMatch = /##\s+Error\s+```([\s\S]*?)```/m.exec(content) - const responseMatch = /##\s+Response\s+([\s\S]*)$/m.exec(content) - const error = errorMatch ? cleanRunPreview(errorMatch[1]) : null + const errorHeading = findMarkdownHeading(content, '## Error') + const responseHeading = findMarkdownHeading(content, '## Response') + const errorRange = errorHeading ? errorContentRange(content, errorHeading.bodyStart) : null + const failed = hasFailedCronHeading(content) || errorHeading !== null + const error = errorRange ? cleanRunPreview(content, errorRange.start, errorRange.end) : null + const previewRange = responseHeading + ? { start: responseHeading.bodyStart, end: content.length } + : (errorRange ?? { start: 0, end: content.length }) return { - status: failed ? 'failed' : responseMatch ? 'completed' : 'unknown', - outputPreview: cleanRunPreview(responseMatch?.[1] ?? errorMatch?.[1] ?? content), + status: failed ? 'failed' : responseHeading ? 'completed' : 'unknown', + outputPreview: cleanRunPreview(content, previewRange.start, previewRange.end), outputContent: content, error } } +function hasFailedCronHeading(content: string): boolean { + let lineStart = 0 + while (lineStart < content.length) { + const lineEnd = lineEndIndex(content, lineStart) + if ( + startsWithAt(content, '#', lineStart) && + lineContains(content, lineStart, lineEnd, 'Cron Job:') && + lineContains(content, lineStart, lineEnd, '(FAILED)') + ) { + return true + } + lineStart = nextLineStart(content, lineEnd) + } + return false +} + +function findMarkdownHeading( + content: string, + heading: '## Error' | '## Response' +): { bodyStart: number } | null { + let lineStart = 0 + while (lineStart < content.length) { + const lineEnd = lineEndIndex(content, lineStart) + if ( + startsWithAt(content, heading, lineStart) && + isHeadingBoundary(content.charCodeAt(lineStart + heading.length)) + ) { + return { bodyStart: nextLineStart(content, lineEnd) } + } + lineStart = nextLineStart(content, lineEnd) + } + return null +} + +function errorContentRange(content: string, bodyStart: number): ContentRange { + const start = skipPreviewWhitespace(content, bodyStart, content.length) + if (!startsWithAt(content, '```', start)) { + return { start, end: nextMarkdownHeadingStart(content, start) ?? content.length } + } + + const fencedStart = nextLineStart(content, lineEndIndex(content, start)) + const fencedEnd = findClosingFence(content, fencedStart) ?? content.length + return { start: fencedStart, end: fencedEnd } +} + +function skipFencedBlock(content: string, fenceStart: number, endIndex: number): number { + const bodyStart = nextLineStart(content, lineEndIndex(content, fenceStart)) + const closeStart = findClosingFence(content, bodyStart) + if (closeStart === null || closeStart > endIndex) { + return endIndex + } + return nextLineStart(content, lineEndIndex(content, closeStart)) +} + +function findClosingFence(content: string, fromIndex: number): number | null { + let lineStart = fromIndex + while (lineStart < content.length) { + const lineEnd = lineEndIndex(content, lineStart) + const textStart = skipPreviewWhitespace(content, lineStart, lineEnd) + if (startsWithAt(content, '```', textStart)) { + return textStart + } + lineStart = nextLineStart(content, lineEnd) + } + return null +} + +function nextMarkdownHeadingStart(content: string, fromIndex: number): number | null { + let lineStart = fromIndex + while (lineStart < content.length) { + const lineEnd = lineEndIndex(content, lineStart) + if (startsWithAt(content, '## ', lineStart)) { + return lineStart + } + lineStart = nextLineStart(content, lineEnd) + } + return null +} + +function lineEndIndex(value: string, startIndex: number): number { + const newline = value.indexOf('\n', startIndex) + return newline === -1 ? value.length : newline +} + +function nextLineStart(value: string, lineEnd: number): number { + return lineEnd < value.length ? lineEnd + 1 : value.length +} + +function lineContains( + value: string, + startIndex: number, + endIndex: number, + needle: string +): boolean { + const index = value.indexOf(needle, startIndex) + return index !== -1 && index < endIndex +} + +function skipPreviewWhitespace(value: string, startIndex: number, endIndex: number): number { + let index = startIndex + while (index < endIndex && isPreviewWhitespace(value.charCodeAt(index))) { + index += 1 + } + return index +} + +function isPreviewWhitespace(code: number): boolean { + return code === 32 || (code >= 9 && code <= 13) +} + +function isHeadingBoundary(code: number): boolean { + return Number.isNaN(code) || isPreviewWhitespace(code) +} + +function startsWithAt(value: string, search: string, startIndex: number): boolean { + if (startIndex + search.length > value.length) { + return false + } + for (let offset = 0; offset < search.length; offset += 1) { + if (value.charCodeAt(startIndex + offset) !== search.charCodeAt(offset)) { + return false + } + } + return true +} + function extractLatestLogPath(content: string): string | null { const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim() if (!rawPath) { diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index d54816d28f3..13de446dc76 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -51,8 +51,17 @@ vi.mock('./cdp-bridge', () => ({ } })) -import { AgentBrowserBridge } from './agent-browser-bridge' +import { + AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, + AgentBrowserBridge +} from './agent-browser-bridge' import type { BrowserManager } from './browser-manager' +import { + CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, + CLIPBOARD_TEXT_WRITE_MAX_BYTES, + CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR +} from '../../shared/clipboard-text' // Why: the bridge resolves webContents via dynamic require('electron').webContents.fromId // inside a try/catch. Override the private method to inject our mock. @@ -1388,6 +1397,27 @@ describe('AgentBrowserBridge', () => { expect(args).toContain('https://example.com') }) + it('rejects oversized browser clipboard writes before spawning agent-browser', async () => { + const secret = 'browser-clipboard-secret' + succeedWith({ ok: true }) + + await expect( + bridge.clipboardWrite(secret + 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)) + ).rejects.toThrow(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR) + + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('rejects browser clipboard writes that exceed the safe agent-browser argument size', async () => { + succeedWith({ ok: true }) + + await expect( + bridge.clipboardWrite('x'.repeat(AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES + 1)) + ).rejects.toThrow(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR) + + expect(execFileMock).not.toHaveBeenCalled() + }) + it('builds valid fill eval JavaScript for multiline values', async () => { succeedWith({ ok: true }) @@ -1402,6 +1432,86 @@ describe('AgentBrowserBridge', () => { expect(() => new Function(expression)).not.toThrow() }) + it('chunks large agent-browser fill values before eval transport', async () => { + const text = ['x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'tail'].join('') + succeedWith({ ok: true }) + + await bridge.fill('@textarea', text) + + const evalCalls = execFileMock.mock.calls.filter((call: unknown[]) => + (call[1] as string[]).includes('eval') + ) + const appendExpressions = evalCalls.slice(1, -1).map((call: unknown[]) => { + const args = call[1] as string[] + return args[args.indexOf('eval') + 1] + }) + + expect(appendExpressions).toHaveLength(2) + expect(appendExpressions.some((expression) => expression.includes(text))).toBe(false) + expect(appendExpressions[0]).toContain('x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES)) + expect(appendExpressions[1]).toContain('tail') + }) + + it.each([ + ['fill', (b: AgentBrowserBridge, text: string) => b.fill('@textarea', text)], + ['type', (b: AgentBrowserBridge, text: string) => b.type(text)], + ['keyboard insert', (b: AgentBrowserBridge, text: string) => b.keyboardInsertText(text)] + ])('yields before spawning agent-browser for accepted large %s text', async (_name, run) => { + vi.useFakeTimers() + try { + const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + succeedWith({ ok: true }) + + const pending = run(bridge, text) + await Promise.resolve() + + expect(execFileMock).not.toHaveBeenCalled() + + await vi.runOnlyPendingTimersAsync() + await pending + + expect(execFileMock).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('chunks large agent-browser type text before keyboard transport', async () => { + const text = ['y'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'zz'].join('') + succeedWith({ typed: true }) + + await bridge.type(text) + + const typeCalls = execFileMock.mock.calls.filter((call: unknown[]) => { + const args = call[1] as string[] + return args.includes('keyboard') && args.includes('type') + }) + const chunks = typeCalls.map((call: unknown[]) => { + const args = call[1] as string[] + return args[args.indexOf('type') + 1] + }) + + expect(chunks).toEqual(['y'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'zz']) + }) + + it('chunks large agent-browser keyboard insert text before transport', async () => { + const text = ['z'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'qq'].join('') + succeedWith({ inserted: true }) + + await bridge.keyboardInsertText(text) + + const insertTextCalls = execFileMock.mock.calls.filter((call: unknown[]) => { + const args = call[1] as string[] + return args.includes('keyboard') && args.includes('inserttext') + }) + const chunks = insertTextCalls.map((call: unknown[]) => { + const args = call[1] as string[] + return args[args.indexOf('inserttext') + 1] + }) + + expect(chunks).toEqual(['z'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'qq']) + }) + // ── Cookie command arg building ── it('builds cookie set args with all options', async () => { diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 29090f19837..60a7019b724 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -47,6 +47,8 @@ import type { BrowserCaptureStopResult, BrowserCookie } from '../../shared/runtime-types' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' // Why: must exceed agent-browser's internal per-command timeouts (goto defaults to 30s, // wait can be up to 60s). Using 90s ensures the bridge never kills a command before @@ -55,6 +57,8 @@ const EXEC_TIMEOUT_MS = 90_000 const CONSECUTIVE_TIMEOUT_LIMIT = 3 const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 +export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 +export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES type SessionState = { proxy: CdpWsProxy @@ -83,6 +87,27 @@ type ResolvedBrowserCommandTarget = { export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' +function focusedValueSetExpression( + valueExpression: string, + options?: { append?: boolean; dispatchEvents?: boolean } +): string { + const nextValue = options?.append + ? ["String(el.value ?? '') + ", valueExpression].join('') + : valueExpression + const dispatchEvents = options?.dispatchEvents + ? " el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));" + : '' + return [ + '(() => { const el = document.activeElement; if (el) {' + + " const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set;", + ' const nextValue = ', + nextValue, + '; if (nativeSetter) { nativeSetter.call(el, nextValue); } else { el.value = nextValue; }', + dispatchEvents, + ' } })()' + ].join('') +} + type AgentBrowserExecOptions = { envOverrides?: NodeJS.ProcessEnv timeoutMs?: number @@ -733,17 +758,30 @@ export class AgentBrowserBridge { worktreeId?: string, browserPageId?: string ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(value) // Why: Input.insertText via Electron's debugger API does not deliver text to // focused inputs in webviews — this is a fundamental Electron limitation. // Agent-browser's fill and click also fail for the same reason. // Workaround: use agent-browser's focus to resolve the ref, then set the value - // directly via JS and dispatch input/change events for React/framework compat. + // directly via chunked JS and dispatch input/change events for React/framework compat. return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { await this.execAgentBrowser(sessionName, ['focus', element]) - const serializedValue = JSON.stringify(value) await this.execAgentBrowser(sessionName, [ 'eval', - `(() => { const el = document.activeElement; if (el) { const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set; if (nativeSetter) { nativeSetter.call(el, ${serializedValue}); } else { el.value = ${serializedValue}; } el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } })()` + focusedValueSetExpression(JSON.stringify('')) + ]) + for (const chunk of iterateBrowserTextInsertionChunks( + value, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(chunk), { append: true }) + ]) + } + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) ]) return { filled: element } as BrowserFillResult }) @@ -754,12 +792,15 @@ export class AgentBrowserBridge { worktreeId?: string, browserPageId?: string ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(input) return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'keyboard', - 'type', - input - ])) as BrowserTypeResult + for (const chunk of iterateBrowserTextInsertionChunks( + input, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk]) + } + return { typed: true } as BrowserTypeResult }) } @@ -836,8 +877,16 @@ export class AgentBrowserBridge { worktreeId?: string, browserPageId?: string ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text) return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', text]) + let result: unknown = { inserted: true } + for (const chunk of iterateBrowserTextInsertionChunks( + text, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk]) + } + return result }) } @@ -1052,6 +1101,9 @@ export class AgentBrowserBridge { worktreeId?: string, browserPageId?: string ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text, { + maxBytes: AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES + }) return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { return await this.execAgentBrowser(sessionName, ['clipboard', 'write', text]) }) diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 05791a982d9..ff8d82e720d 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -18,6 +18,7 @@ import { importCookiesFromFile, importCookiesFromBrowser, detectInstalledBrowsers, + summarizeCookieImportError, type ChromiumCookieColumnInfo, type DetectedBrowser } from './browser-cookie-import' @@ -27,6 +28,19 @@ import { tmpdir } from 'node:os' const LARGE_SAFARI_COOKIE_COUNT = 150_000 +describe('summarizeCookieImportError', () => { + it('folds a bounded error preview without full-string whitespace replacement', () => { + const replaceSpy = vi.spyOn(String.prototype, 'replace') + const message = `Import failed\n\t${'secret-cookie-value '.repeat(20_000)}` + + const summary = summarizeCookieImportError(new Error(message)) + + expect(summary.length).toBeLessThanOrEqual(180) + expect(summary).toContain('Import failed secret-cookie-value') + expect(replaceSpy).not.toHaveBeenCalled() + }) +}) + function buildSafariBinaryCookies(cookieCount: number): Buffer { const cookies: Buffer[] = [] const offsets: number[] = [] diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 7079141f4e6..921460243e2 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -35,9 +35,32 @@ function getDiagLogPath(): string { function reasonWithDiagLog(reason: string): string { return `${reason} Details were written to ${getDiagLogPath()}.` } -function describeImportError(err: unknown): string { +const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180 +const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512 + +// Why: imported cookie errors can include pasted or file-derived payloads; +// diagnostics only need a short preview, not a full-string whitespace pass. +export function summarizeCookieImportError(err: unknown): string { const raw = err instanceof Error && err.message ? err.message : String(err) - return raw.replace(/\s+/g, ' ').slice(0, 180) + let summary = '' + let previousWasWhitespace = false + const scanLimit = Math.min(raw.length, COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS) + for (let index = 0; index < scanLimit; index += 1) { + const code = raw.charCodeAt(index) + if (code === 32 || (code >= 9 && code <= 13)) { + if (summary.length > 0 && !previousWasWhitespace) { + summary += ' ' + } + previousWasWhitespace = true + continue + } + summary += raw.charAt(index) + if (summary.length >= COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) { + return summary.slice(0, COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) + } + previousWasWhitespace = false + } + return summary } function diag(msg: string): void { const line = `[${new Date().toISOString()}] ${msg}\n` @@ -1768,7 +1791,7 @@ export async function importCookiesFromBrowser( return { ok: false, reason: reasonWithDiagLog( - `Could not import cookies from ${browser.label}: ${describeImportError(err)}.` + `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` ) } } diff --git a/src/main/browser/browser-text-insertion.test.ts b/src/main/browser/browser-text-insertion.test.ts new file mode 100644 index 00000000000..3541fca8538 --- /dev/null +++ b/src/main/browser/browser-text-insertion.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import { + BROWSER_TEXT_INSERT_CHUNK_BYTES, + insertTextThroughCdp, + iterateBrowserTextInsertionChunks, + splitBrowserTextInsertionChunks +} from './browser-text-insertion' + +describe('browser text insertion chunking', () => { + it('keeps small text as a single CDP insertion chunk', () => { + expect(splitBrowserTextInsertionChunks('hello')).toEqual(['hello']) + }) + + it('splits by UTF-8 bytes without splitting surrogate pairs', () => { + const chunks = splitBrowserTextInsertionChunks('ab😀cd', 4) + + expect(chunks).toEqual(['ab', '😀', 'cd']) + expect(chunks.join('')).toBe('ab😀cd') + }) + + it('iterates insertion chunks lazily without prebuilding the full chunk array', () => { + const chunks = iterateBrowserTextInsertionChunks('abcdefghij', 4) + + expect(chunks.next()).toEqual({ done: false, value: 'abcd' }) + expect(chunks.next()).toEqual({ done: false, value: 'efgh' }) + expect(chunks.next()).toEqual({ done: false, value: 'ij' }) + expect(chunks.next()).toEqual({ done: true, value: undefined }) + }) + + it('keeps a single multibyte character intact when the byte cap is smaller', () => { + const chunks = splitBrowserTextInsertionChunks('😀a', 1) + + expect(chunks).toEqual(['😀', 'a']) + expect(chunks.join('')).toBe('😀a') + }) + + it('sends bounded CDP insertText chunks in order', async () => { + const sender = vi.fn().mockResolvedValue({}) + const text = 'x'.repeat(BROWSER_TEXT_INSERT_CHUNK_BYTES + 3) + + await insertTextThroughCdp(sender, text, { yieldBetweenChunks: false }) + + expect(sender).toHaveBeenCalledTimes(2) + expect(sender).toHaveBeenNthCalledWith(1, 'Input.insertText', { + text: 'x'.repeat(BROWSER_TEXT_INSERT_CHUNK_BYTES) + }) + expect(sender).toHaveBeenNthCalledWith(2, 'Input.insertText', { text: 'xxx' }) + }) + + it('does not scan the full payload before the first CDP insertion resolves', async () => { + let releaseFirstChunk: (() => void) | undefined + let callCount = 0 + const sender = vi.fn(() => { + callCount += 1 + if (callCount === 1) { + return new Promise((resolve) => { + releaseFirstChunk = resolve + }) + } + return Promise.resolve() + }) + const codePointAt = vi.spyOn(String.prototype, 'codePointAt') + const text = 'x'.repeat(128) + + const pending = insertTextThroughCdp(sender, text, { + maxChunkBytes: 8, + yieldBetweenChunks: false + }) + await Promise.resolve() + + expect(sender).toHaveBeenCalledTimes(1) + expect(sender).toHaveBeenCalledWith('Input.insertText', { text: 'x'.repeat(8) }) + expect(codePointAt.mock.calls.length).toBeLessThan(text.length) + + releaseFirstChunk?.() + await pending + }) +}) diff --git a/src/main/browser/browser-text-insertion.ts b/src/main/browser/browser-text-insertion.ts new file mode 100644 index 00000000000..9acee214399 --- /dev/null +++ b/src/main/browser/browser-text-insertion.ts @@ -0,0 +1,77 @@ +import { measureClipboardTextByteLength } from '../../shared/clipboard-text' +import type { CdpCommandSender } from './snapshot-engine' + +export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024 + +function getUtf8ByteLengthForCodePoint(codePoint: number): number { + if (codePoint <= 0x7f) { + return 1 + } + if (codePoint <= 0x7ff) { + return 2 + } + if (codePoint <= 0xffff) { + return 3 + } + return 4 +} + +export function splitBrowserTextInsertionChunks( + text: string, + maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES +): string[] { + return [...iterateBrowserTextInsertionChunks(text, maxChunkBytes)] +} + +export function* iterateBrowserTextInsertionChunks( + text: string, + maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES +): Generator { + if (text.length === 0) { + return + } + const normalizedMax = Number.isFinite(maxChunkBytes) && maxChunkBytes > 0 ? maxChunkBytes : 1 + const measurement = measureClipboardTextByteLength(text, { stopAfterBytes: normalizedMax }) + if (!measurement.exceededLimit) { + yield text + return + } + + let currentStart = 0 + let currentBytes = 0 + let index = 0 + while (index < text.length) { + const codePoint = text.codePointAt(index) ?? 0 + const codeUnitLength = codePoint > 0xffff ? 2 : 1 + const nextIndex = index + codeUnitLength + const characterBytes = getUtf8ByteLengthForCodePoint(codePoint) + if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) { + yield text.slice(currentStart, index) + currentStart = index + currentBytes = 0 + } + currentBytes += characterBytes + index = nextIndex + } + if (currentStart < text.length) { + yield text.slice(currentStart) + } +} + +export async function insertTextThroughCdp( + sender: CdpCommandSender, + text: string, + options?: { yieldBetweenChunks?: boolean; maxChunkBytes?: number } +): Promise { + const chunks = iterateBrowserTextInsertionChunks(text, options?.maxChunkBytes) + let chunk = chunks.next() + while (!chunk.done) { + await sender('Input.insertText', { text: chunk.value }) + // Why: browser automation text can be paste-sized; yielding keeps the main + // process responsive between bounded CDP payloads. + chunk = chunks.next() + if (options?.yieldBetweenChunks !== false && !chunk.done) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } +} diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index eab3e74b6c2..a86c1be9947 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -24,6 +24,7 @@ vi.mock('../git/worktree', () => ({ import { BrowserManager } from './browser-manager' import { CdpBridge } from './cdp-bridge' +import { BROWSER_TEXT_INSERT_CHUNK_BYTES } from './browser-text-insertion' import { OrcaRuntimeService } from '../runtime/orca-runtime' import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import { readRuntimeMetadata } from '../runtime/runtime-metadata' @@ -474,6 +475,24 @@ describe('Browser automation pipeline (integration)', () => { expect((res.result as { filled: string }).filled).toBe('@e2') }) + it('chunks large browser fill text before CDP insertText', async () => { + await rpc('browser.goto', { url: 'https://search.example.com' }) + await rpc('browser.snapshot') + + const text = 'x'.repeat(BROWSER_TEXT_INSERT_CHUNK_BYTES + 5) + const res = await rpc('browser.fill', { element: '@e2', value: text }) + + const insertCalls = activeGuestHarness.sendCommandMock.mock.calls.filter( + ([method]) => method === 'Input.insertText' + ) + expect(res.ok).toBe(true) + expect(insertCalls).toHaveLength(2) + expect((insertCalls[0]![1] as { text: string }).text).toHaveLength( + BROWSER_TEXT_INSERT_CHUNK_BYTES + ) + expect((insertCalls[1]![1] as { text: string }).text).toBe('xxxxx') + }) + // ── Type ── it('types text at current focus', async () => { @@ -482,6 +501,21 @@ describe('Browser automation pipeline (integration)', () => { expect((res.result as { typed: boolean }).typed).toBe(true) }) + it('chunks large browser type text before CDP insertText', async () => { + const text = 'y'.repeat(BROWSER_TEXT_INSERT_CHUNK_BYTES + 2) + const res = await rpc('browser.type', { input: text }) + + const insertCalls = activeGuestHarness.sendCommandMock.mock.calls.filter( + ([method]) => method === 'Input.insertText' + ) + expect(res.ok).toBe(true) + expect(insertCalls).toHaveLength(2) + expect((insertCalls[0]![1] as { text: string }).text).toHaveLength( + BROWSER_TEXT_INSERT_CHUNK_BYTES + ) + expect((insertCalls[1]![1] as { text: string }).text).toBe('yy') + }) + // ── Select ── it('selects a dropdown option by ref', async () => { diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index 5390455a2d9..bda4831a790 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -45,6 +45,7 @@ import { type RefEntry, type SnapshotResult } from './snapshot-engine' +import { insertTextThroughCdp } from './browser-text-insertion' import type { BrowserManager } from './browser-manager' import { ANTI_DETECTION_SCRIPT } from './anti-detection' @@ -337,7 +338,7 @@ export class CdpBridge { await sender('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Delete' }) await sender('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Delete' }) - await sender('Input.insertText', { text: value }) + await insertTextThroughCdp(sender, value) // Why: React and other frameworks use synthetic event listeners that may not // detect native keyboard events. Explicitly dispatching input/change ensures @@ -366,7 +367,7 @@ export class CdpBridge { const sender = this.makeCdpSender(guest) await this.ensureDebuggerAttached(guest) - await sender('Input.insertText', { text: input }) + await insertTextThroughCdp(sender, input) return { typed: true } }) } diff --git a/src/main/browser/grab-guest-script.test.ts b/src/main/browser/grab-guest-script.test.ts index 4455b1d0420..17f9f95b483 100644 --- a/src/main/browser/grab-guest-script.test.ts +++ b/src/main/browser/grab-guest-script.test.ts @@ -128,12 +128,16 @@ describe('buildGuestOverlayScript', () => { expect(script).toContain('!SAFE_URL_PROTOCOLS.has(u.protocol)') }) - it('arm script slices text nodes before normalizing bounded text', () => { + it('arm script folds bounded text without joining text-node chunks', () => { const script = buildGuestOverlayScript('arm') - expect(script).toContain("normalizeText((node.nodeValue || '').slice(0, remaining))") + expect(script).toContain( + "appendNormalizedText(acc, (node.nodeValue || '').slice(0, remaining), max)" + ) + expect(script).toContain('appendNormalizedText(acc, value, BUDGET.selectedTextMaxLength)') expect(script).toContain('value = value.slice(start, end)') - expect(script).not.toContain('normalizeText(node.nodeValue ||') + expect(script).not.toContain("chunks.join(' ')") + expect(script).not.toContain('replace(/\\s+/g') expect(script).not.toContain("(el.textContent || '').trim()") expect(script).not.toContain('ref.textContent') }) @@ -145,4 +149,12 @@ describe('buildGuestOverlayScript', () => { expect(script).toContain('nextElementSibling') expect(script).not.toContain('Array.from(parent.children)') }) + + it('arm script tokenizes aria-labelledby without regex splitting', () => { + const script = buildGuestOverlayScript('arm') + + expect(script).toContain('getAriaLabelledByIds') + expect(script).toContain('isAriaLabelledBySeparator') + expect(script).not.toContain('ariaLabelledBy.split(/\\s+/)') + }) }) diff --git a/src/main/browser/grab-guest-script.ts b/src/main/browser/grab-guest-script.ts index a07f4b70892..0613d7319eb 100644 --- a/src/main/browser/grab-guest-script.ts +++ b/src/main/browser/grab-guest-script.ts @@ -136,30 +136,58 @@ const ARM_SCRIPT = `(function() { } } - function normalizeText(text) { - return String(text || '').trim().replace(/\\s+/g, ' '); + function createTextAccumulator() { + return { text: '', pendingSpace: false }; + } + + function isWhitespaceCode(code) { + return code === 32 || (code >= 9 && code <= 13) || code === 160 || + code === 5760 || (code >= 8192 && code <= 8202) || code === 8232 || + code === 8233 || code === 8239 || code === 8287 || code === 12288 || + code === 65279; + } + + function appendTextSeparator(acc) { + if (acc.text.length > 0) acc.pendingSpace = true; + } + + function appendNormalizedText(acc, text, max) { + var limit = max + 20; + var value = String(text || ''); + for (var i = 0; i < value.length && acc.text.length < limit; i++) { + var code = value.charCodeAt(i); + if (isWhitespaceCode(code)) { + if (acc.text.length > 0) acc.pendingSpace = true; + continue; + } + if (acc.pendingSpace) { + acc.text += ' '; + acc.pendingSpace = false; + if (acc.text.length >= limit) break; + } + acc.text += value.charAt(i); + } + } + + function finishAccumulatedText(acc, max) { + return clampStr(acc.text, max); } function getBoundedText(el, max) { try { var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); - var chunks = []; - var length = 0; + var acc = createTextAccumulator(); var inspected = 0; var node = walker.nextNode(); - while (node && length < max + 20 && inspected < TEXT_NODE_SCAN_LIMIT) { + while (node && acc.text.length < max + 20 && inspected < TEXT_NODE_SCAN_LIMIT) { inspected++; - var separatorLength = chunks.length > 0 ? 1 : 0; - var remaining = max + 20 - length - separatorLength; + appendTextSeparator(acc); + var remaining = max + 20 - acc.text.length - (acc.pendingSpace ? 1 : 0); if (remaining <= 0) break; - var value = normalizeText((node.nodeValue || '').slice(0, remaining)); - if (value) { - chunks.push(value.slice(0, remaining)); - length += Math.min(value.length, remaining) + separatorLength; - } + appendNormalizedText(acc, (node.nodeValue || '').slice(0, remaining), max); node = walker.nextNode(); } - return clampStr(normalizeText(chunks.join(' ')), max); + return finishAccumulatedText(acc, max); } catch (e) { return ''; } @@ -173,10 +201,13 @@ const ARM_SCRIPT = `(function() { try { var selection = window.getSelection ? window.getSelection() : null; if (!selection || selection.rangeCount === 0) return ''; - var chunks = []; - var length = 0; + var acc = createTextAccumulator(); var inspected = 0; - for (var i = 0; i < selection.rangeCount && length < BUDGET.selectedTextMaxLength + 20; i++) { + for ( + var i = 0; + i < selection.rangeCount && acc.text.length < BUDGET.selectedTextMaxLength + 20; + i++ + ) { var range = selection.getRangeAt(i); var walkerRoot = range.commonAncestorContainer; var walker = document.createTreeWalker( @@ -194,14 +225,15 @@ const ARM_SCRIPT = `(function() { var node = walkerRoot.nodeType === Node.TEXT_NODE ? walkerRoot : walker.nextNode(); while ( node && - length < BUDGET.selectedTextMaxLength + 20 && + acc.text.length < BUDGET.selectedTextMaxLength + 20 && inspected < TEXT_NODE_SCAN_LIMIT ) { inspected++; var textNode = node; var value = textNode.nodeValue || ''; - var separatorLength = chunks.length > 0 ? 1 : 0; - var remaining = BUDGET.selectedTextMaxLength + 20 - length - separatorLength; + appendTextSeparator(acc); + var remaining = + BUDGET.selectedTextMaxLength + 20 - acc.text.length - (acc.pendingSpace ? 1 : 0); if (remaining <= 0) break; if (value) { var start = textNode === range.startContainer ? range.startOffset : 0; @@ -213,16 +245,12 @@ const ARM_SCRIPT = `(function() { start = Math.min(start, value.length); } value = value.slice(start, end); - value = normalizeText(value); - } - if (value) { - chunks.push(value.slice(0, remaining)); - length += Math.min(value.length, remaining) + separatorLength; + appendNormalizedText(acc, value, BUDGET.selectedTextMaxLength); } node = walker.nextNode(); } } - return clampStr(chunks.join(' '), BUDGET.selectedTextMaxLength); + return finishAccumulatedText(acc, BUDGET.selectedTextMaxLength); } catch (e) { return ''; } @@ -263,6 +291,40 @@ const ARM_SCRIPT = `(function() { return attrs; } + // Why: guest pages control aria-labelledby; avoid regex splitting huge + // attributes while extracting grab payload accessibility metadata. + function getAriaLabelledByIds(value) { + var ids = []; + var tokenStart = -1; + for (var index = 0; index <= value.length; index++) { + var isEnd = index === value.length; + if (!isEnd && !isAriaLabelledBySeparator(value.charCodeAt(index))) { + if (tokenStart === -1) tokenStart = index; + continue; + } + if (tokenStart !== -1) { + ids.push(value.slice(tokenStart, index)); + tokenStart = -1; + if (ids.length >= 32) break; + } + } + return ids; + } + + function isAriaLabelledBySeparator(code) { + return code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279; + } + function getAccessibility(el) { var role = el.getAttribute('role') || el.tagName.toLowerCase(); var ariaLabel = el.getAttribute('aria-label') || null; @@ -272,7 +334,7 @@ const ARM_SCRIPT = `(function() { if (ariaLabel) { accessibleName = ariaLabel; } else if (ariaLabelledBy) { - var parts = ariaLabelledBy.split(/\\s+/); + var parts = getAriaLabelledByIds(ariaLabelledBy); var names = []; for (var i = 0; i < parts.length; i++) { var ref = document.getElementById(parts[i]); diff --git a/src/main/computer/computer-clipboard-paste-validation.ts b/src/main/computer/computer-clipboard-paste-validation.ts new file mode 100644 index 00000000000..18e2f4f7209 --- /dev/null +++ b/src/main/computer/computer-clipboard-paste-validation.ts @@ -0,0 +1,42 @@ +import { + CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, + CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR, + assertClipboardTextWriteWithinLimit, + assertClipboardTextWriteWithinLimitWithYield, + isClipboardTextWriteTooLargeError +} from '../../shared/clipboard-text' +import { RuntimeClientError } from './runtime-client-error' + +export function validateComputerClipboardPasteText(text: string): void { + try { + assertClipboardTextWriteWithinLimit(text) + } catch (error) { + if (isClipboardTextWriteTooLargeError(error)) { + throw new RuntimeClientError('invalid_argument', CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR) + } + throw error + } +} + +export async function validateComputerClipboardPasteTextWithYield(text: string): Promise { + try { + // Why: accepted paste-sized payloads must not monopolize the main process + // while preserving the provider-facing invalid_argument error contract. + await assertClipboardTextWriteWithinLimitWithYield(text) + } catch (error) { + if (isClipboardTextWriteTooLargeError(error)) { + throw new RuntimeClientError('invalid_argument', CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR) + } + throw error + } +} + +export function validateComputerClipboardPasteTextWithBoundedYield( + text: string +): Promise | void { + if (text.length <= CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS) { + validateComputerClipboardPasteText(text) + return + } + return validateComputerClipboardPasteTextWithYield(text) +} diff --git a/src/main/computer/computer-provider-action-validation.ts b/src/main/computer/computer-provider-action-validation.ts index 33b982af0b6..6c75288e142 100644 --- a/src/main/computer/computer-provider-action-validation.ts +++ b/src/main/computer/computer-provider-action-validation.ts @@ -2,6 +2,7 @@ import { computerUseHotkeyValidationMessage, computerUsePressKeyValidationMessage } from '../../shared/computer-use-key-spec' +import { validateComputerClipboardPasteTextWithBoundedYield } from './computer-clipboard-paste-validation' import { RuntimeClientError } from './runtime-client-error' type ComputerProviderActionMethod = @@ -15,10 +16,10 @@ type ComputerProviderActionMethod = | 'pasteText' | 'setValue' -export function validateComputerProviderActionParams( +export async function validateComputerProviderActionParams( method: ComputerProviderActionMethod, params: Record -): string { +): Promise { const app = requireNonEmptyString(params, 'app') validateWindowTarget(params) switch (method) { @@ -40,9 +41,11 @@ export function validateComputerProviderActionParams( validateDragTarget(params) return app case 'typeText': - case 'pasteText': requireNonEmptyString(params, 'text') return app + case 'pasteText': + await validatePasteText(params) + return app case 'pressKey': validatePressKey(params) return app @@ -182,6 +185,11 @@ function validateHotkey(params: Record): void { } } +function validatePasteText(params: Record): Promise | void { + const text = requireNonEmptyString(params, 'text') + return validateComputerClipboardPasteTextWithBoundedYield(text) +} + function requireStringAllowingEmpty(params: Record, key: string): string { const value = params[key] if (typeof value !== 'string') { diff --git a/src/main/computer/computer-sidecar-paste-validation.ts b/src/main/computer/computer-sidecar-paste-validation.ts new file mode 100644 index 00000000000..4131fc4fa45 --- /dev/null +++ b/src/main/computer/computer-sidecar-paste-validation.ts @@ -0,0 +1,15 @@ +import { validateComputerClipboardPasteTextWithBoundedYield } from './computer-clipboard-paste-validation' + +export function validateComputerSidecarPasteText( + method: string, + params: unknown +): Promise | void { + if (method !== 'pasteText' || !params || typeof params !== 'object') { + return + } + const text = (params as Record).text + if (typeof text !== 'string') { + return + } + return validateComputerClipboardPasteTextWithBoundedYield(text) +} diff --git a/src/main/computer/desktop-script-provider-action-errors.test.ts b/src/main/computer/desktop-script-provider-action-errors.test.ts index 056b7fbbfa8..ef835834400 100644 --- a/src/main/computer/desktop-script-provider-action-errors.test.ts +++ b/src/main/computer/desktop-script-provider-action-errors.test.ts @@ -1,4 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest' +import { + CLIPBOARD_TEXT_WRITE_MAX_BYTES, + CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR +} from '../../shared/clipboard-text' import { createDesktopScriptProviderClient, expectDesktopProviderSubprocessStartCount, @@ -175,6 +179,15 @@ describe('DesktopScriptProviderClient action errors', () => { message: expect.stringContaining('Missing text') } ) + await expect( + client.action('pasteText', { + app: 'Text Editor', + text: ['native-provider-secret', 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)].join('') + }) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR + }) await expect( client.action('pressKey', { app: 'Text Editor', key: 'CmdOrCtrl+V' }) ).rejects.toMatchObject({ diff --git a/src/main/computer/desktop-script-provider-client.ts b/src/main/computer/desktop-script-provider-client.ts index 014c740a1d0..691a8d1b2bd 100644 --- a/src/main/computer/desktop-script-provider-client.ts +++ b/src/main/computer/desktop-script-provider-client.ts @@ -125,7 +125,7 @@ export class DesktopScriptProviderClient { method: NativeActionMethod, params: Record ): Promise { - const app = validateComputerProviderActionParams(method, params) + const app = await validateComputerProviderActionParams(method, params) const explicitWindowId = optionalNumberParam(params, 'windowId') const explicitWindowIndex = optionalNumberParam(params, 'windowIndex') const current = this.snapshotStore.current(app, explicitWindowId, params) diff --git a/src/main/computer/desktop-script-provider-paste-validation.test.ts b/src/main/computer/desktop-script-provider-paste-validation.test.ts new file mode 100644 index 00000000000..e4fd0843222 --- /dev/null +++ b/src/main/computer/desktop-script-provider-paste-validation.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' +import { + createDesktopScriptProviderClient, + expectDesktopProviderSubprocessStartCount, + mockBridgeResponse, + resetDesktopScriptProviderTestHarness, + sampleBridgeSnapshot, + sampleCapabilities +} from './desktop-script-provider-test-harness' + +describe('DesktopScriptProviderClient paste validation', () => { + afterEach(resetDesktopScriptProviderTestHarness) + + it('yields while validating large accepted pasteText payloads before launching the provider', async () => { + vi.useFakeTimers() + mockBridgeResponse({ + ok: true, + capabilities: sampleCapabilities() + }) + mockBridgeResponse({ + ok: true, + action: { + path: 'clipboard', + actionName: 'paste', + fallbackReason: null + }, + snapshot: sampleBridgeSnapshot('Text Editor', 'pasted') + }) + const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py') + const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + + const call = client.action('pasteText', { + app: 'Text Editor', + text, + noScreenshot: true + }) + await Promise.resolve() + + expectDesktopProviderSubprocessStartCount(0) + + await vi.advanceTimersByTimeAsync(0) + await expect(call).resolves.toMatchObject({ + action: { path: 'clipboard' } + }) + expectDesktopProviderSubprocessStartCount(2) + }) +}) diff --git a/src/main/computer/macos-native-provider-client.ts b/src/main/computer/macos-native-provider-client.ts index 331e486efdb..29dbfa8729c 100644 --- a/src/main/computer/macos-native-provider-client.ts +++ b/src/main/computer/macos-native-provider-client.ts @@ -56,7 +56,7 @@ export class MacOSNativeProviderClient { return (await this.call('getAppState', params)) as ComputerSnapshotResult } async action(method: NativeActionMethod, params: unknown): Promise { - validateComputerProviderActionParams( + await validateComputerProviderActionParams( method, params && typeof params === 'object' ? (params as Record) : {} ) diff --git a/src/main/computer/macos-native-provider-paste-validation.test.ts b/src/main/computer/macos-native-provider-paste-validation.test.ts new file mode 100644 index 00000000000..05fb748dd3e --- /dev/null +++ b/src/main/computer/macos-native-provider-paste-validation.test.ts @@ -0,0 +1,164 @@ +import { EventEmitter } from 'events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' + +const { + chmodSyncMock, + connectMacOSProviderSocketMock, + mkdtempSyncMock, + resolveMacOSComputerUseExecutablePathMock, + rmSyncMock, + spawnMock, + writeFileSyncMock +} = vi.hoisted(() => ({ + chmodSyncMock: vi.fn(), + connectMacOSProviderSocketMock: vi.fn(), + mkdtempSyncMock: vi.fn(), + resolveMacOSComputerUseExecutablePathMock: vi.fn(), + rmSyncMock: vi.fn(), + spawnMock: vi.fn(), + writeFileSyncMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +vi.mock('fs', () => ({ + chmodSync: chmodSyncMock, + mkdtempSync: mkdtempSyncMock, + rmSync: rmSyncMock, + writeFileSync: writeFileSyncMock +})) + +vi.mock('./macos-native-provider-paths', () => ({ + resolveMacOSComputerUseExecutablePath: resolveMacOSComputerUseExecutablePathMock +})) + +vi.mock('./macos-native-provider-socket', () => ({ + connectMacOSProviderSocket: connectMacOSProviderSocketMock +})) + +class FakeSocket extends EventEmitter { + destroyed = false + writes: string[] = [] + + setEncoding(): void {} + + write(line: string, callback?: (error?: Error | null) => void): boolean { + this.writes.push(line) + callback?.(null) + return true + } + + end(): void { + this.destroyed = true + } + + destroy(): this { + this.destroyed = true + return this + } +} + +class FakeProvider extends EventEmitter { + kill = vi.fn() + unref = vi.fn() +} + +async function loadClientModule() { + vi.resetModules() + return await import('./macos-native-provider-client') +} + +function macOSProviderCapabilities() { + return { + platform: 'darwin', + provider: 'orca-computer-use-macos', + providerVersion: '1.0.0', + protocolVersion: 1, + supports: { + actions: { + pasteText: true + } + } + } +} + +describe('MacOSNativeProviderClient paste validation', () => { + const sockets: FakeSocket[] = [] + const providers: FakeProvider[] = [] + + beforeEach(() => { + vi.useFakeTimers() + sockets.length = 0 + providers.length = 0 + mkdtempSyncMock.mockImplementation((prefix: string) => `${prefix}${sockets.length}`) + resolveMacOSComputerUseExecutablePathMock.mockReturnValue( + '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos' + ) + spawnMock.mockImplementation(() => { + const provider = new FakeProvider() + providers.push(provider) + return provider + }) + connectMacOSProviderSocketMock.mockImplementation(async () => { + const socket = new FakeSocket() + sockets.push(socket) + return socket + }) + }) + + afterEach(() => { + chmodSyncMock.mockReset() + connectMacOSProviderSocketMock.mockReset() + mkdtempSyncMock.mockReset() + resolveMacOSComputerUseExecutablePathMock.mockReset() + rmSyncMock.mockReset() + spawnMock.mockReset() + writeFileSyncMock.mockReset() + vi.useRealTimers() + }) + + it('yields while validating large accepted pasteText payloads before starting the helper', async () => { + const { MacOSNativeProviderClient } = await loadClientModule() + const client = new MacOSNativeProviderClient() + const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + + const call = client.action('pasteText', { app: 'TextEdit', text }) + await Promise.resolve() + + expect(providers).toHaveLength(0) + expect(sockets).toHaveLength(0) + expect(spawnMock).not.toHaveBeenCalled() + expect(connectMacOSProviderSocketMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => expect(sockets).toHaveLength(1)) + const socket = sockets[0]! + await vi.waitFor(() => expect(socket.writes).toHaveLength(1)) + const handshakeRequest = JSON.parse(socket.writes[0]!) as { id: number } + socket.emit( + 'data', + `${JSON.stringify({ + id: handshakeRequest.id, + ok: true, + result: macOSProviderCapabilities() + })}\n` + ) + await vi.waitFor(() => expect(socket.writes).toHaveLength(2)) + const actionRequest = JSON.parse(socket.writes[1]!) as { id: number } + socket.emit( + 'data', + `${JSON.stringify({ + id: actionRequest.id, + ok: true, + result: { action: { path: 'clipboard', actionName: 'paste' } } + })}\n` + ) + + await expect(call).resolves.toMatchObject({ + action: { path: 'clipboard' } + }) + }) +}) diff --git a/src/main/computer/sidecar-client.test.ts b/src/main/computer/sidecar-client.test.ts index 7a802f1adfa..387ceceefe5 100644 --- a/src/main/computer/sidecar-client.test.ts +++ b/src/main/computer/sidecar-client.test.ts @@ -1,5 +1,10 @@ import { EventEmitter } from 'events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, + CLIPBOARD_TEXT_WRITE_MAX_BYTES, + CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR +} from '../../shared/clipboard-text' import { callComputerSidecarAction, callComputerSidecarCapabilities, @@ -235,6 +240,45 @@ describe('computer sidecar client', () => { }) }) + it('rejects oversized pasteText payloads before forking the sidecar', async () => { + const secret = 'sidecar-secret-token' + + await expect( + callComputerSidecarAction('pasteText', { + app: 'Finder', + text: secret + 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1) + }) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR + }) + + expect(children).toHaveLength(0) + }) + + it('yields while validating large accepted pasteText payloads before forking', async () => { + const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + + const call = callComputerSidecarAction('pasteText', { app: 'Finder', text }) + await Promise.resolve() + + expect(children).toHaveLength(0) + + await vi.advanceTimersByTimeAsync(0) + expect(children).toHaveLength(1) + const child = children[0]! + const request = child.sent[0]! + child.emit('message', { + id: request.id, + ok: true, + result: { action: { path: 'clipboard', actionName: 'paste' } } + }) + + await expect(call).resolves.toMatchObject({ + action: { path: 'clipboard' } + }) + }) + it('rejects queued requests after the active request times out', async () => { const firstCall = callComputerSidecarCapabilities() const secondCall = callComputerSidecarCapabilities() diff --git a/src/main/computer/sidecar-client.ts b/src/main/computer/sidecar-client.ts index 33513ed340c..56b83f2b2cf 100644 --- a/src/main/computer/sidecar-client.ts +++ b/src/main/computer/sidecar-client.ts @@ -8,6 +8,7 @@ import type { ComputerSnapshotResult } from '../../shared/runtime-types' import { normalizeComputerActionResult } from './computer-action-verification-normalization' +import { validateComputerSidecarPasteText } from './computer-sidecar-paste-validation' import { RuntimeClientError } from './runtime-client-error' type ComputerSidecarMethod = @@ -85,6 +86,10 @@ export async function callComputerSidecarAction( >, params: unknown ): Promise { + const validation = validateComputerSidecarPasteText(method, params) + if (validation) { + await validation + } return normalizeComputerActionResult( (await getComputerSidecar().call(method, params)) as ComputerActionResult ) diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index c429fe981a8..04979326649 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -1,14 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' -import { join } from 'path' +import { basename, join } from 'path' import { createServer, connect, type Server } from 'net' import { DaemonServer } from './daemon-server' -import { getDaemonPidPath, getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner' +import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' import { getProcessStartedAtMs, healthCheckDaemon, killStaleDaemon, + parseLinuxBootTimeSeconds, + parseLinuxProcStartTicks, parseDaemonPidFile, startTimeMatches } from './daemon-health' @@ -58,6 +60,12 @@ function canConnect(socketPath: string): Promise { }) } +function daemonTestSocketPath(dir: string): string { + return process.platform === 'win32' + ? `\\\\.\\pipe\\${basename(dir)}-daemon.sock` + : join(dir, 'daemon.sock') +} + describe('daemon health', () => { let dir: string let socketPath: string @@ -65,7 +73,7 @@ describe('daemon health', () => { beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'daemon-health-test-')) - socketPath = getDaemonSocketPath(dir) + socketPath = daemonTestSocketPath(dir) tokenPath = join(dir, 'daemon.token') }) @@ -199,6 +207,35 @@ describe('parseDaemonPidFile', () => { }) }) +describe('Linux process start-time parsing', () => { + it('parses start ticks from proc stat with spaces in the command name', () => { + const fields = Array.from({ length: 20 }, (_, index) => String(index + 1)) + fields[0] = 'S' + fields[19] = '987654' + + expect(parseLinuxProcStartTicks(`123 (orca daemon) ${fields.join(' ')}`)).toBe(987654) + }) + + it('parses boot time seconds from proc stat output', () => { + expect(parseLinuxBootTimeSeconds('cpu 1 2 3\r\nbtime 1700000000\nintr 1')).toBe(1_700_000_000) + }) + + it('does not use line-array or whitespace-regex splitting', () => { + const splitSpy = vi.spyOn(String.prototype, 'split') + + parseLinuxProcStartTicks('123 (orca daemon) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 42') + parseLinuxBootTimeSeconds('cpu 1 2 3\nbtime 1700000000') + + const usedUnboundedSplit = splitSpy.mock.calls.some( + ([separator]) => + (typeof separator === 'string' && (separator === '\n' || separator === ' ')) || + (separator instanceof RegExp && separator.source.includes('\\s+')) + ) + splitSpy.mockRestore() + expect(usedUnboundedSplit).toBe(false) + }) +}) + describe('startTimeMatches', () => { it('returns true when expected is null (legacy pid file)', () => { // The real process pid is irrelevant here — null short-circuits before @@ -252,7 +289,7 @@ describe('killStaleDaemon pid identity guards', () => { beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'daemon-health-pid-test-')) - socketPath = join(dir, 'daemon.sock') + socketPath = daemonTestSocketPath(dir) tokenPath = join(dir, 'daemon.token') }) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 8982e908fe9..ab52374f410 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -4,6 +4,10 @@ import { execFile, execFileSync } from 'child_process' import { existsSync, readFileSync, unlinkSync } from 'fs' import { connect, type Socket } from 'net' import { promisify } from 'util' +import { + getProcessOutputFields, + iterateProcessOutputLines +} from '../../shared/process-output-field-scanner' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' import { encodeNdjson } from './ndjson' import { getDaemonPidPath } from './daemon-spawner' @@ -316,13 +320,8 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { function getLinuxProcessStartedAtMs(pid: number): number | null { try { const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') - const afterCommand = stat.slice(stat.lastIndexOf(')') + 2) - const fields = afterCommand.split(' ') - const startTicks = Number(fields[19]) - const bootTimeLine = readFileSync('/proc/stat', 'utf8') - .split('\n') - .find((line) => line.startsWith('btime ')) - const bootTimeSeconds = bootTimeLine ? Number(bootTimeLine.split(/\s+/)[1]) : Number.NaN + const startTicks = parseLinuxProcStartTicks(stat) + const bootTimeSeconds = parseLinuxBootTimeSeconds(readFileSync('/proc/stat', 'utf8')) const ticksPerSecond = Number( execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8', timeout: 1_000 }).trim() ) @@ -340,6 +339,26 @@ function getLinuxProcessStartedAtMs(pid: number): number | null { } } +export function parseLinuxProcStartTicks(stat: string): number { + const commandEndIndex = stat.lastIndexOf(')') + if (commandEndIndex === -1) { + return Number.NaN + } + + const fields = getProcessOutputFields(stat.slice(commandEndIndex + 1), 20) + return Number(fields[19]) +} + +export function parseLinuxBootTimeSeconds(procStat: string): number { + for (const line of iterateProcessOutputLines(procStat)) { + if (!line.startsWith('btime ')) { + continue + } + return Number(getProcessOutputFields(line, 2)[1]) + } + return Number.NaN +} + export function getProcessStartedAtMs(pid: number): number | null { if (process.platform === 'linux') { return getLinuxProcessStartedAtMs(pid) diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index c2ab445cacd..1a7dd6c5abe 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest' import { HeadlessEmulator } from './headless-emulator' +function expectedNativePath(posixPath: string): string { + return posixPath +} + describe('HeadlessEmulator', () => { let emulator: HeadlessEmulator @@ -101,30 +105,38 @@ describe('HeadlessEmulator', () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b]7;file://localhost/Users/test/project\x07') - expect(emulator.getSnapshot().cwd).toBe('/Users/test/project') + expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/Users/test/project')) }) it('handles OSC-7 with empty host', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b]7;file:///home/user/work\x07') - expect(emulator.getSnapshot().cwd).toBe('/home/user/work') + expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/home/user/work')) }) it('updates CWD when new OSC-7 arrives', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b]7;file:///first\x07') - expect(emulator.getSnapshot().cwd).toBe('/first') + expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/first')) await emulator.write('\x1b]7;file:///second\x07') - expect(emulator.getSnapshot().cwd).toBe('/second') + expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/second')) + }) + + it('keeps earlier file CWD when a later OSC-7 URI is unsupported', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b]7;file:///kept\x07\x1b]7;http://example.invalid/rejected\x07') + + expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/kept')) }) it('decodes percent-encoded paths', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b]7;file:///Users/test/my%20project\x07') - expect(emulator.getSnapshot().cwd).toBe('/Users/test/my project') + expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/Users/test/my project')) }) it('normalizes Windows drive-letter OSC-7 paths', async () => { @@ -163,7 +175,7 @@ describe('HeadlessEmulator', () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b]7;file:///path/here\x1b\\') - expect(emulator.getSnapshot().cwd).toBe('/path/here') + expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/path/here')) }) it('tracks OSC-7 CWD across split PTY chunks', async () => { @@ -172,7 +184,7 @@ describe('HeadlessEmulator', () => { await emulator.write('\x1b]7;file:///split') await emulator.write('/project\x07') - expect(emulator.getSnapshot().cwd).toBe('/split/project') + expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/split/project')) }) it('tracks OSC-7 CWD when ESC and OSC marker arrive in separate chunks', async () => { @@ -181,7 +193,7 @@ describe('HeadlessEmulator', () => { await emulator.write('\x1b') await emulator.write(']7;file:///split-escape\x07') - expect(emulator.getSnapshot().cwd).toBe('/split-escape') + expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/split-escape')) }) }) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 9ccc53c69f0..5376df92f6b 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -3,6 +3,7 @@ import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' import { extractLastOscTitle } from '../../shared/agent-detection' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' +import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' import { parseFileUriPath } from './osc7-file-uri' import type { TerminalSnapshot, TerminalModes } from './types' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' @@ -189,28 +190,13 @@ export class HeadlessEmulator { } private scanOsc7(data: string): void { - // OSC-7 format: ESC ] 7 ; BEL or ESC ] 7 ; ST - // BEL = \x07, ST = ESC \ - // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars - const osc7Re = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g - let match: RegExpExecArray | null - while ((match = osc7Re.exec(data)) !== null) { - this.parseOsc7Uri(match[1]) - } + scanOsc7Uris(data, (uri) => { + this.parseOsc7Uri(uri) + }) } private extractOscScanTail(input: string): string { - const lastOsc = input.lastIndexOf('\x1b]') - const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1 - const start = Math.max(lastOsc, lastEscape) - if (start === -1) { - return '' - } - const suffix = input.slice(start) - if (suffix.includes('\x07') || suffix.includes('\x1b\\')) { - return '' - } - return suffix.slice(-OSC_SCAN_TAIL_LIMIT) + return extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT) } private scanPrivateModes(data: string): void { diff --git a/src/main/daemon/osc7-uri-extraction.test.ts b/src/main/daemon/osc7-uri-extraction.test.ts new file mode 100644 index 00000000000..872ac44b811 --- /dev/null +++ b/src/main/daemon/osc7-uri-extraction.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { extractLastOsc7Uri, extractOscScanTail } from './osc7-uri-extraction' + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('OSC-7 URI extraction', () => { + it('extracts BEL and ST terminated OSC-7 URIs', () => { + expect(extractLastOsc7Uri('\x1b]7;file:///first\x07noise\x1b]7;file:///second\x1b\\')).toBe( + 'file:///second' + ) + }) + + it('recovers when abandoned incomplete OSC data is followed by a fresh URI', () => { + expect(extractLastOsc7Uri('\x1b]7;file:///abandoned\x1b]7;file:///fresh\x07')).toBe( + 'file:///fresh' + ) + }) + + it('keeps only bounded incomplete OSC tail text', () => { + const tail = extractOscScanTail(`\x1b]7;file:///${'x'.repeat(10_000)}`, 128) + + expect(tail).toHaveLength(128) + }) + + it('scans large pasted OSC-like output without regex iteration', () => { + const execSpy = vi.spyOn(RegExp.prototype, 'exec') + const data = `${'pasted \x1b]x;noise\x07 '.repeat(10_000)}\x1b]7;file:///repo\x07` + + expect(extractLastOsc7Uri(data)).toBe('file:///repo') + expect(execSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/osc7-uri-extraction.ts b/src/main/daemon/osc7-uri-extraction.ts new file mode 100644 index 00000000000..97d335b6a6b --- /dev/null +++ b/src/main/daemon/osc7-uri-extraction.ts @@ -0,0 +1,81 @@ +const OSC7_PREFIX = '\x1b]7;' +const ESC_CODE_UNIT = 0x1b +const BEL_CODE_UNIT = 0x07 +const BACKSLASH_CODE_UNIT = 0x5c + +type Osc7ParseResult = + | { kind: 'uri'; uri: string; nextIndex: number } + | { kind: 'invalid'; nextIndex: number } + | { kind: 'incomplete' } + +function parseOsc7At(data: string, index: number): Osc7ParseResult { + if (!data.startsWith(OSC7_PREFIX, index)) { + return { kind: 'invalid', nextIndex: index + 1 } + } + + const uriStart = index + OSC7_PREFIX.length + for (let cursor = uriStart; cursor < data.length; cursor += 1) { + const code = data.charCodeAt(cursor) + if (code === BEL_CODE_UNIT) { + return { kind: 'uri', uri: data.slice(uriStart, cursor), nextIndex: cursor + 1 } + } + if (code !== ESC_CODE_UNIT) { + continue + } + if (data.charCodeAt(cursor + 1) === BACKSLASH_CODE_UNIT) { + return { kind: 'uri', uri: data.slice(uriStart, cursor), nextIndex: cursor + 2 } + } + return { kind: 'invalid', nextIndex: cursor } + } + + return { kind: 'incomplete' } +} + +export function scanOsc7Uris(data: string, onUri: (uri: string) => void): void { + if (!data.includes(OSC7_PREFIX)) { + return + } + + let searchStart = 0 + while (searchStart < data.length) { + const start = data.indexOf(OSC7_PREFIX, searchStart) + if (start === -1) { + break + } + + const parsed = parseOsc7At(data, start) + if (parsed.kind === 'incomplete') { + break + } + if (parsed.kind === 'uri') { + onUri(parsed.uri) + searchStart = parsed.nextIndex + continue + } + searchStart = parsed.nextIndex + } +} + +export function extractLastOsc7Uri(data: string): string | null { + let lastUri: string | null = null + scanOsc7Uris(data, (uri) => { + lastUri = uri + }) + + return lastUri +} + +export function extractOscScanTail(input: string, limit: number): string { + const lastOsc = input.lastIndexOf('\x1b]') + const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1 + const start = Math.max(lastOsc, lastEscape) + if (start === -1) { + return '' + } + + const suffix = input.slice(start) + if (suffix.includes('\x07') || suffix.includes('\x1b\\')) { + return '' + } + return suffix.slice(-limit) +} diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts index e7b90c7fcc1..15dfd8c1db8 100644 --- a/src/main/devin/hook-service.test.ts +++ b/src/main/devin/hook-service.test.ts @@ -19,7 +19,7 @@ import { DevinHookService } from './hook-service' import { getDevinConfigPath, getDevinManagedCommand, - getDevinManagedScriptFileName + getDevinManagedScriptPath } from './hook-settings' describe('DevinHookService', () => { @@ -28,12 +28,13 @@ describe('DevinHookService', () => { beforeEach(() => { homeDir = mkdtempSync(join(tmpdir(), 'orca-devin-home-')) homedirMock.mockReturnValue(homeDir) - vi.stubEnv('APPDATA', join(homeDir, '.config')) + vi.stubEnv('APPDATA', join(homeDir, 'AppData', 'Roaming')) }) afterEach(() => { vi.unstubAllEnvs() vi.clearAllMocks() + vi.unstubAllEnvs() rmSync(homeDir, { recursive: true, force: true }) }) @@ -42,12 +43,10 @@ describe('DevinHookService', () => { expect(status.state).toBe('installed') expect(status.agent).toBe('devin') - expect(status.configPath).toBe(join(homeDir, '.config', 'devin', 'config.json')) + expect(status.configPath).toBe(getDevinConfigPath()) expect(status.managedHooksPresent).toBe(true) - const config = JSON.parse( - readFileSync(join(homeDir, '.config', 'devin', 'config.json'), 'utf8') - ) as { + const config = JSON.parse(readFileSync(getDevinConfigPath(), 'utf8')) as { hooks: Record agent?: { model: string } } @@ -63,15 +62,12 @@ describe('DevinHookService', () => { for (const eventName of ['PreToolUse', 'PostToolUse', 'PermissionRequest']) { expect(config.hooks[eventName][0].matcher).toBeUndefined() } - const script = readFileSync( - join(homeDir, '.orca', 'agent-hooks', getDevinManagedScriptFileName()), - 'utf8' - ) + const script = readFileSync(getDevinManagedScriptPath(), 'utf8') expect(script).toContain('/hook/devin') }) it('preserves unrelated keys in Devin config when installing hooks', () => { - const configPath = join(homeDir, '.config', 'devin', 'config.json') + const configPath = getDevinConfigPath() mkdirSync(dirname(configPath), { recursive: true }) writeFileSync( configPath, @@ -89,7 +85,7 @@ describe('DevinHookService', () => { }) it('installs when Devin config uses JSONC comments', () => { - const configPath = join(homeDir, '.config', 'devin', 'config.json') + const configPath = getDevinConfigPath() mkdirSync(dirname(configPath), { recursive: true }) writeFileSync( configPath, @@ -107,7 +103,7 @@ describe('DevinHookService', () => { }) it('surfaces read_config_from overlap in status detail', () => { - const configPath = join(homeDir, '.config', 'devin', 'config.json') + const configPath = getDevinConfigPath() mkdirSync(dirname(configPath), { recursive: true }) writeFileSync( configPath, @@ -134,7 +130,7 @@ describe('DevinHookService', () => { }) it('reports not_installed when Devin config has no managed hooks', () => { - const configPath = join(homeDir, '.config', 'devin', 'config.json') + const configPath = getDevinConfigPath() mkdirSync(dirname(configPath), { recursive: true }) writeFileSync(configPath, `${JSON.stringify({ hooks: {} }, null, 2)}\n`) @@ -152,7 +148,7 @@ describe('DevinHookService', () => { const removed = service.remove() expect(removed.state).toBe('not_installed') - const configPath = join(homeDir, '.config', 'devin', 'config.json') + const configPath = getDevinConfigPath() const config = JSON.parse(readFileSync(configPath, 'utf8')) as { hooks: Record } @@ -163,8 +159,8 @@ describe('DevinHookService', () => { }) it('returns partial status when some managed hooks are missing', () => { - const configPath = join(homeDir, '.config', 'devin', 'config.json') - const scriptPath = join(homeDir, '.orca', 'agent-hooks', getDevinManagedScriptFileName()) + const configPath = getDevinConfigPath() + const scriptPath = getDevinManagedScriptPath() const command = getDevinManagedCommand(scriptPath) mkdirSync(dirname(configPath), { recursive: true }) mkdirSync(dirname(scriptPath), { recursive: true }) diff --git a/src/main/emulator/serve-sim-helper-processes.test.ts b/src/main/emulator/serve-sim-helper-processes.test.ts index 9f95a72a204..ef9673e2700 100644 --- a/src/main/emulator/serve-sim-helper-processes.test.ts +++ b/src/main/emulator/serve-sim-helper-processes.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { parseServeSimHelperProcesses } from './serve-sim-helper-processes' describe('parseServeSimHelperProcesses', () => { @@ -21,4 +21,24 @@ describe('parseServeSimHelperProcesses', () => { } ]) }) + + it('scans ps output without line-array splitting', () => { + const splitSpy = vi.spyOn(String.prototype, 'split') + try { + const helpers = parseServeSimHelperProcesses( + '201 /Applications/serve-sim/bin/serve-sim-bin UDID-1 --port 3100\r\n' + ) + const usedOutputSplit = splitSpy.mock.calls.some(([separator]) => { + const pattern = separator as unknown + return ( + pattern === '\n' || + (pattern instanceof RegExp && (pattern.source === '\\r?\\n' || pattern.source === '\\s+')) + ) + }) + expect(helpers).toHaveLength(1) + expect(usedOutputSplit).toBe(false) + } finally { + splitSpy.mockRestore() + } + }) }) diff --git a/src/main/emulator/serve-sim-helper-processes.ts b/src/main/emulator/serve-sim-helper-processes.ts index 34811cfce47..f26b9f04b1d 100644 --- a/src/main/emulator/serve-sim-helper-processes.ts +++ b/src/main/emulator/serve-sim-helper-processes.ts @@ -1,5 +1,7 @@ import { execFile } from 'child_process' import { platform } from 'os' +import { commandContainsToken } from '../../shared/command-token-scanner' +import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' export type ServeSimHelperProcess = { pid: number @@ -25,7 +27,7 @@ function execFileText(command: string, args: string[]): Promise { export function parseServeSimHelperProcesses(psOutput: string): ServeSimHelperProcess[] { const helpers: ServeSimHelperProcess[] = [] - for (const line of psOutput.split('\n')) { + for (const line of iterateProcessOutputLines(psOutput)) { const match = /^\s*(\d+)\s+(.+)$/.exec(line) if (!match) { continue @@ -41,7 +43,7 @@ export function parseServeSimHelperProcesses(psOutput: string): ServeSimHelperPr } function commandTargetsDevice(command: string, deviceUdid: string): boolean { - return command.split(/\s+/).includes(deviceUdid) + return commandContainsToken(command, deviceUdid) } export async function listServeSimHelperProcessesForDevice( diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index b2b43e9c04f..e3d12f140f8 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -4,6 +4,7 @@ import { existsSync, statSync } from 'fs' import { basename } from 'path' import { gitExecFileSync, gitExecFileAsync } from './runner' import type { BaseRefSearchResult } from '../../shared/types' +import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl, @@ -363,13 +364,11 @@ export function getRemoteDrift( ['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`], gitExecOptions(repoPath, options) ) - const [aheadStr, behindStr] = stdout.trim().split(/\s+/) - const ahead = Number(aheadStr) - const behind = Number(behindStr) - if (!Number.isFinite(ahead) || !Number.isFinite(behind)) { + const counts = parseGitRevListAheadBehindCounts(stdout) + if (counts.status !== 'ok') { return null } - return { ahead, behind } + return { ahead: counts.ahead, behind: counts.behind } } catch { return null } diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts index e83af8c115f..f27d421222a 100644 --- a/src/main/git/runner.test.ts +++ b/src/main/git/runner.test.ts @@ -1,9 +1,13 @@ // Why: covers two recent classifier fixes — Retry-After honoring on 429 // (transient detection must propagate, not silently retry on 250ms cadence) // and stderr extraction from execFile rejections (err.message is unreliable). -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { extractExecError, isTransientGhError, parseRetryAfterMs } from './runner' +afterEach(() => { + vi.restoreAllMocks() +}) + describe('parseRetryAfterMs', () => { it('returns null when no Retry-After is present', () => { expect(parseRetryAfterMs('HTTP 429 Too Many Requests')).toBeNull() @@ -17,6 +21,20 @@ describe('parseRetryAfterMs', () => { expect(parseRetryAfterMs(' retry-after: 12 \n')).toBe(12_000) }) + it('parses large stderr output without full-string retry-after matching', () => { + const matchSpy = vi.spyOn(String.prototype, 'match') + const stderr = `${'noise\n'.repeat(10_000)}Retry-After: 12\n` + + expect(parseRetryAfterMs(stderr)).toBe(12_000) + const usedRetryAfterMatch = matchSpy.mock.calls.some( + ([pattern]) => + pattern instanceof RegExp && + pattern.source.startsWith('retry-after:') && + pattern.source.includes('[^\\r\\n]') + ) + expect(usedRetryAfterMatch).toBe(false) + }) + it('returns null for malformed values', () => { expect(parseRetryAfterMs('Retry-After: not-a-date')).toBeNull() }) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index a66bdba08ae..23b57d86327 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1154,11 +1154,10 @@ export function extractExecError(err: unknown): { stderr: string; stdout: string * and burns the retry budget. Also supports HTTP-date Retry-After values. */ export function parseRetryAfterMs(stderr: string): number | null { - const m = stderr.match(/retry-after:\s*([^\r\n]+)/i) - if (!m) { + const raw = findRetryAfterHeaderValue(stderr) + if (raw === null) { return null } - const raw = m[1].trim() if (/^\d+$/.test(raw)) { const seconds = Number(raw) return Number.isFinite(seconds) ? seconds * 1000 : null @@ -1170,6 +1169,50 @@ export function parseRetryAfterMs(stderr: string): number | null { return Math.max(0, ts - Date.now()) } +function findRetryAfterHeaderValue(stderr: string): string | null { + const headerIndex = indexOfAsciiIgnoreCase(stderr, 'retry-after:', 0) + if (headerIndex === -1) { + return null + } + let valueStart = headerIndex + 'retry-after:'.length + while (valueStart < stderr.length) { + const code = stderr.charCodeAt(valueStart) + if (code !== 9 && code !== 32) { + break + } + valueStart++ + } + let valueEnd = valueStart + while (valueEnd < stderr.length) { + const code = stderr.charCodeAt(valueEnd) + if (code === 10 || code === 13) { + break + } + valueEnd++ + } + const value = stderr.slice(valueStart, valueEnd).trim() + return value.length > 0 ? value : null +} + +function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number { + const lastStart = value.length - search.length + for (let index = Math.max(0, fromIndex); index <= lastStart; index++) { + let matches = true + for (let offset = 0; offset < search.length; offset++) { + const code = value.charCodeAt(index + offset) + const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code + if (normalizedCode !== search.charCodeAt(offset)) { + matches = false + break + } + } + if (matches) { + return index + } + } + return -1 +} + /** * Classify whether a gh execFile rejection is worth retrying. * diff --git a/src/main/git/status.ts b/src/main/git/status.ts index e2c134fd939..4097adaf1e6 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -47,6 +47,7 @@ import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' +import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES @@ -774,8 +775,8 @@ export async function getCommitCompare( ['rev-list', '--parents', '-n', '1', commitOid], gitOptionsForWorktree(worktreePath, options) ) - const [, firstParent] = stdout.trim().split(/\s+/) - summary.parentOid = firstParent ?? null + const firstParent = parseGitRevListFirstParentOid(stdout) + summary.parentOid = firstParent summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' const entries = await loadCommitChanges(worktreePath, summary.parentOid, commitOid, options) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 45277832a3f..59213f73e88 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -13,6 +13,7 @@ import type { LocalBaseRefUpdateSuggestion, RemoveWorktreeResult } from '../../shared/types' +import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir } from './status' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' @@ -148,13 +149,8 @@ function parseRemoteTrackingLocalBaseRef( } function parseRevListDrift(output: string): { ahead: number; behind: number } | null { - const [aheadStr, behindStr] = output.trim().split(/\s+/) - const ahead = Number(aheadStr) - const behind = Number(behindStr) - if (!Number.isFinite(ahead) || !Number.isFinite(behind) || ahead < 0 || behind < 0) { - return null - } - return { ahead, behind } + const counts = parseGitRevListAheadBehindCounts(output) + return counts.status === 'ok' ? { ahead: counts.ahead, behind: counts.behind } : null } async function evaluateLocalBaseRefRefreshability( diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 8ffbc2d23a5..e6ce6005c69 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -75,6 +75,7 @@ import { _resetMergeQueueCacheForTests, _resetOwnerRepoCache } from './client' +import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' describe('listWorkItems', () => { beforeEach(() => { @@ -277,6 +278,28 @@ describe('listWorkItems', () => { ) }) + it('rejects oversized queries before resolving repo sources or executing gh', async () => { + const secret = 'main-github-work-items-secret' + const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) + + await expect(listWorkItems('/repo-root', 10, oversizedQuery)).resolves.toEqual({ + items: [], + sources: { + issues: null, + prs: null, + originCandidate: null, + upstreamCandidate: null + } + }) + + expect(resolveIssueSourceMock).not.toHaveBeenCalled() + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(getOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(acquireMock).not.toHaveBeenCalled() + expect(releaseMock).not.toHaveBeenCalled() + }) + it('hydrates PR list rows with repository merge metadata', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) @@ -517,6 +540,20 @@ describe('listWorkItems', () => { expect(apiPath).not.toContain('-is:merged') }) + it('returns zero for oversized count queries before resolving repo sources', async () => { + const secret = 'main-github-work-items-secret' + const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) + + await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0) + + expect(resolveIssueSourceMock).not.toHaveBeenCalled() + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(getOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(acquireMock).not.toHaveBeenCalled() + expect(releaseMock).not.toHaveBeenCalled() + }) + it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 26b7f879948..f4d80db2a67 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -28,6 +28,7 @@ import { normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github-pr-merge-methods' +import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-query-bounds' import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE, @@ -1259,13 +1260,24 @@ export async function listWorkItems( noCache?: boolean, localGitOptions: LocalGitExecOptions = {} ): Promise> { + const trimmedQuery = query?.trim() ?? '' + if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) { + return { + items: [], + sources: { + issues: null, + prs: null, + originCandidate: null, + upstreamCandidate: null + } + } + } const [issueResolved, prResolved] = await Promise.all([ resolveIssueSource(repoPath, preference, connectionId, localGitOptions), resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) ]) const issueOwnerRepo = issueResolved.source const prOwnerRepo = prResolved.source - const trimmedQuery = query?.trim() ?? '' await acquire() try { // Why: errors propagate to IPC so the renderer's cross-repo aggregator can @@ -1417,6 +1429,10 @@ export async function countWorkItems( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + const trimmedQuery = query?.trim() ?? '' + if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) { + return 0 + } const [issueResolved, prResolved] = await Promise.all([ resolveIssueSource(repoPath, preference, connectionId, localGitOptions), resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) @@ -1428,7 +1444,6 @@ export async function countWorkItems( return 0 } - const trimmedQuery = query?.trim() ?? '' const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery() diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index ac75c7b6d28..00a8844bb34 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -8,6 +8,10 @@ // (d) parseProjectPaste shorthand owner-only alphabet matches the renderer, // (e) project owner/capability caches stay bounded in long sessions. import { beforeEach, describe, expect, it } from 'vitest' +import { + GITHUB_PROJECT_REF_INPUT_MAX_BYTES, + GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR +} from '../../shared/github-project-ref-input' import { PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES, _getProjectViewCacheSizesForTests, @@ -21,7 +25,8 @@ import { classifyProjectError, isValidOwnerSlug, isValidRepoSlug, - parseProjectPaste + parseProjectPaste, + resolveProjectRef } from './project-view' describe('classifyProjectError', () => { @@ -151,6 +156,39 @@ describe('parseProjectPaste', () => { expect(parseProjectPaste('')).toBeNull() expect(parseProjectPaste(' ')).toBeNull() }) + + it('rejects oversized valid-looking URLs without parsing the secret-bearing tail', () => { + const secret = 'project-url-secret' + const input = [ + 'https://github.com/orgs/acme/projects/42?', + secret, + 'x'.repeat(GITHUB_PROJECT_REF_INPUT_MAX_BYTES) + ].join('') + + expect(parseProjectPaste(input)).toBeNull() + }) +}) + +describe('resolveProjectRef', () => { + it('rejects oversized project refs with a metadata-only validation error', async () => { + const secret = 'project-url-secret' + const input = [ + 'https://github.com/orgs/acme/projects/42?', + secret, + 'x'.repeat(GITHUB_PROJECT_REF_INPUT_MAX_BYTES) + ].join('') + + await expect(resolveProjectRef({ input })).resolves.toEqual({ + ok: false, + error: { + type: 'validation_error', + message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR + } + }) + await expect(resolveProjectRef({ input })).resolves.not.toMatchObject({ + error: { message: expect.stringContaining(secret) } + }) + }) }) describe('project view owner caches', () => { diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 67a60fc9989..64835133d71 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -47,6 +47,10 @@ import type { ResolveProjectRefArgs, ResolveProjectRefResult } from '../../shared/github-project-types' +import { + GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, + isGitHubProjectRefInputTooLarge +} from '../../shared/github-project-ref-input' // Re-export the public API so existing call sites (`./project-view`) keep // working unchanged. The split is internal-only. @@ -1565,6 +1569,9 @@ export function parseProjectPaste(input: string): ParsedPaste | null { if (!trimmed) { return null } + if (isGitHubProjectRefInputTooLarge(trimmed)) { + return null + } // URL forms const urlRe = /^https?:\/\/github\.com\/(orgs|users)\/([^/]+)\/projects\/(\d+)(?:\/views\/(\d+))?/i @@ -1686,13 +1693,20 @@ async function resolveOwnerType( export async function resolveProjectRef( args: ResolveProjectRefArgs ): Promise { - if (typeof args.input !== 'string' || !args.input.trim()) { + const input = typeof args.input === 'string' ? args.input.trim() : '' + if (!input) { return { ok: false, error: { type: 'validation_error', message: 'Input required.' } } } - const parsed = parseProjectPaste(args.input) + if (isGitHubProjectRefInputTooLarge(input)) { + return { + ok: false, + error: { type: 'validation_error', message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR } + } + } + const parsed = parseProjectPaste(input) if (!parsed) { return { ok: false, diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index aae63efebe6..67566f6eec8 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -361,6 +361,19 @@ describe('parseGlabApiResponse', () => { expect(parsed.body).toBe('[]') }) + it('splits large bodies without full-output separator matching', () => { + const matchSpy = vi.spyOn(String.prototype, 'match') + const body = '[{"iid":1}]'.repeat(10_000) + const parsed = parseGlabApiResponse(`HTTP/2.0 200 OK\r\nX-Total: 7\r\n\r\n${body}`) + + expect(parsed.headers['x-total']).toBe('7') + expect(parsed.body).toBe(body) + const usedSeparatorMatch = matchSpy.mock.calls.some( + ([pattern]) => pattern instanceof RegExp && pattern.source === '\\r?\\n\\r?\\n' + ) + expect(usedSeparatorMatch).toBe(false) + }) + it('lowercases header names for stable lookup', () => { const stdout = 'HTTP/2.0 200 OK\nX-Total: 1\nContent-Type: application/json\n\n{}' const parsed = parseGlabApiResponse(stdout) diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index 33ec28b2de1..fe5f08faf48 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -1,6 +1,7 @@ import { execFile } from 'child_process' import { promisify } from 'util' import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' +import { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response' // Why: legacy generic execFile wrapper - only used by callers that don't need // WSL-aware routing. Repo-scoped callers should use the runner exports below. @@ -27,6 +28,7 @@ export type { ProjectRef, ResolvedIssueSource } from './gitlab-project-ref-resolution' +export { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response' const MAX_CONCURRENT = 4 let running = 0 @@ -53,11 +55,6 @@ export function release(): void { } } -export type GlabApiResponse = { - body: string - headers: Record -} - export async function glabApiWithHeaders( args: string[], options?: { cwd?: string } @@ -65,22 +62,3 @@ export async function glabApiWithHeaders( const { stdout } = await glabExecFileAsync(['api', '-i', ...args], options) return parseGlabApiResponse(stdout) } - -/** @internal - exported for tests. */ -export function parseGlabApiResponse(stdout: string): GlabApiResponse { - const sepMatch = stdout.match(/\r?\n\r?\n/) - if (!sepMatch || sepMatch.index === undefined) { - return { body: stdout, headers: {} } - } - const headerBlock = stdout.slice(0, sepMatch.index) - const body = stdout.slice(sepMatch.index + sepMatch[0].length) - const headers: Record = {} - const lines = headerBlock.split(/\r?\n/) - for (const line of lines) { - const m = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/) - if (m) { - headers[m[1].toLowerCase()] = m[2].trim() - } - } - return { body, headers } -} diff --git a/src/main/gitlab/glab-api-response.ts b/src/main/gitlab/glab-api-response.ts new file mode 100644 index 00000000000..a8ea6380769 --- /dev/null +++ b/src/main/gitlab/glab-api-response.ts @@ -0,0 +1,46 @@ +export type GlabApiResponse = { + body: string + headers: Record +} + +/** @internal - exported for tests through gl-utils. */ +export function parseGlabApiResponse(stdout: string): GlabApiResponse { + // Why: response is HTTP status, headers, blank line, then body. + // Find the first blank line (CRLF or LF) as the boundary. + const separator = findHeaderBodySeparator(stdout) + if (!separator) { + return { body: stdout, headers: {} } + } + const headerBlock = stdout.slice(0, separator.index) + const body = stdout.slice(separator.bodyStart) + const headers: Record = {} + // Skip the status line and parse the rest as key: value. + const lines = headerBlock.split(/\r?\n/) + for (const line of lines) { + const m = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/) + if (m) { + headers[m[1].toLowerCase()] = m[2].trim() + } + } + return { body, headers } +} + +function findHeaderBodySeparator(stdout: string): { index: number; bodyStart: number } | null { + let lineStart = 0 + for (let index = 0; index < stdout.length; index++) { + const code = stdout.charCodeAt(index) + if (code !== 10 && code !== 13) { + continue + } + + const lineEnd = index + const nextLineStart = + stdout.charCodeAt(index) === 13 && stdout.charCodeAt(index + 1) === 10 ? index + 2 : index + 1 + if (lineEnd === lineStart) { + return { index: lineStart, bodyStart: nextLineStart } + } + lineStart = nextLineStart + index = nextLineStart - 1 + } + return null +} diff --git a/src/main/gitlab/glab-known-hosts.ts b/src/main/gitlab/glab-known-hosts.ts new file mode 100644 index 00000000000..157e579ed66 --- /dev/null +++ b/src/main/gitlab/glab-known-hosts.ts @@ -0,0 +1,79 @@ +import { glabExecFileAsync } from '../git/runner' +import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost, type ProjectRef } from './project-ref-parser' + +let knownHostsCache: readonly string[] | null = null + +export function rememberGlabKnownHost(host: string): void { + const normalizedHost = normalizeGitLabHost(host) + if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) { + return + } + knownHostsCache = [...knownHostsCache, normalizedHost] +} + +export async function isGlabConfiguredForRemoteHost( + repoPath: string, + projectRef: Pick, + connectionId?: string | null +): Promise { + try { + const result = await glabExecFileAsync( + ['auth', 'status', '--hostname', projectRef.host], + connectionId ? {} : { cwd: repoPath } + ) + return result !== undefined + } catch (error) { + const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown } + const output = + [execLike.stdout, execLike.stderr, execLike.message] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n') || String(error) + const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost) + return hosts.includes(normalizeGitLabHost(projectRef.host)) + } +} + +/** @internal - exposed for tests only */ +export function _resetKnownHostsCache(): void { + knownHostsCache = null +} + +export async function getGlabKnownHosts(): Promise { + if (knownHostsCache) { + return knownHostsCache + } + try { + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) + // Why: glab writes auth status to stderr in some versions, stdout in + // others. Concatenate so the parser sees both. + const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) + // Always include gitlab.com so a fresh-install user with no auth + // still recognizes the canonical host. + const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) + knownHostsCache = merged + return merged + } catch { + // Auth check failed (glab not installed, no auth, etc.) - fall back + // to the canonical default. The caller will hit the auth error on + // the first real request anyway. + knownHostsCache = [...DEFAULT_GITLAB_HOSTS] + return knownHostsCache + } +} + +// Why: glab auth status output is human-formatted and varies across versions. +// Match observed logged-in lines and host headers, dedupe, lowercase. +export function parseGlabAuthStatusHosts(output: string): string[] { + const hosts = new Set() + for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) { + hosts.add(m[1].toLowerCase()) + } + for (const line of output.split('\n')) { + const bareLine = line.trim() + const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine + if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) { + hosts.add(hostLine.toLowerCase()) + } + } + return Array.from(hosts) +} diff --git a/src/main/headless-automation-dispatcher-source-boundary.test.ts b/src/main/headless-automation-dispatcher-source-boundary.test.ts new file mode 100644 index 00000000000..d8cc23c28a3 --- /dev/null +++ b/src/main/headless-automation-dispatcher-source-boundary.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(__dirname, 'index.ts'), 'utf8') + +function sourceBetween(startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('headless automation dispatcher source boundaries', () => { + it('creates new-per-run workspaces from the resolved run target repo', () => { + const createArgsSection = sourceBetween('buildHeadlessAutomationWorktreeCreateArgs({', '})') + + expect(createArgsSection).toContain('repo: target.repo') + expect(createArgsSection).not.toContain('automation.sourceContext') + }) +}) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index dcaa1fc98e9..4ead1dc4d2e 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -33,6 +33,13 @@ vi.mock('./git/runner', () => ({ gitExecFileSync: gitExecFileSyncMock })) +const TEST_REPO_PATH = join('/test/repo') +const TEST_WORKTREE_PATH = join('/test/worktree') +const TEST_REPO_ORCA_YAML_PATH = join(TEST_REPO_PATH, 'orca.yaml') +const TEST_WORKTREE_ORCA_YAML_PATH = join(TEST_WORKTREE_PATH, 'orca.yaml') +const TEST_ISSUE_COMMAND_PATH = join(TEST_REPO_PATH, '.orca', 'issue-command') +const TEST_GITIGNORE_PATH = join(TEST_REPO_PATH, '.gitignore') + describe('parseOrcaYaml', () => { it('parses YAML with setup script only', () => { const yaml = `scripts:\n setup: |\n echo "setting up"\n npm install\n` @@ -255,51 +262,45 @@ describe('hasUnrecognizedOrcaYamlKeys', () => { describe('readIssueCommand', () => { it('prefers the local override over the shared orca.yaml command', async () => { const fs = await import('fs') - const repoPath = join('/test', 'repo') - const localIssueCommandPath = join(repoPath, '.orca', 'issue-command') - const sharedConfigPath = join(repoPath, 'orca.yaml') vi.mocked(fs.existsSync).mockImplementation( - (path) => path === localIssueCommandPath || path === sharedConfigPath + (path) => path === TEST_ISSUE_COMMAND_PATH || path === TEST_REPO_ORCA_YAML_PATH ) vi.mocked(fs.readFileSync).mockImplementation((path) => { - if (path === localIssueCommandPath) { + if (path === TEST_ISSUE_COMMAND_PATH) { return 'local command\n' } - if (path === sharedConfigPath) { + if (path === TEST_REPO_ORCA_YAML_PATH) { return 'issueCommand: |\n shared command\n' } return '' }) const { readIssueCommand } = await import('./hooks') - expect(readIssueCommand(repoPath)).toEqual({ + expect(readIssueCommand(TEST_REPO_PATH)).toEqual({ localContent: 'local command', sharedContent: 'shared command', effectiveContent: 'local command', - localFilePath: localIssueCommandPath, + localFilePath: TEST_ISSUE_COMMAND_PATH, source: 'local' }) }) it('falls back to the shared orca.yaml command when no local override exists', async () => { const fs = await import('fs') - const repoPath = join('/test', 'repo') - const localIssueCommandPath = join(repoPath, '.orca', 'issue-command') - const sharedConfigPath = join(repoPath, 'orca.yaml') - vi.mocked(fs.existsSync).mockImplementation((path) => path === sharedConfigPath) + vi.mocked(fs.existsSync).mockImplementation((path) => path === TEST_REPO_ORCA_YAML_PATH) vi.mocked(fs.readFileSync).mockImplementation((path) => { - if (path === sharedConfigPath) { + if (path === TEST_REPO_ORCA_YAML_PATH) { return 'issueCommand: |\n shared command\n' } return '' }) const { readIssueCommand } = await import('./hooks') - expect(readIssueCommand(repoPath)).toEqual({ + expect(readIssueCommand(TEST_REPO_PATH)).toEqual({ localContent: null, sharedContent: 'shared command', effectiveContent: 'shared command', - localFilePath: localIssueCommandPath, + localFilePath: TEST_ISSUE_COMMAND_PATH, source: 'shared' }) }) @@ -308,48 +309,92 @@ describe('readIssueCommand', () => { describe('writeIssueCommand', () => { it('writes only the local override file and keeps .orca ignored locally', async () => { const fs = await import('fs') - const repoPath = join('/test', 'repo') - const gitignorePath = join(repoPath, '.gitignore') - const localOrcaDir = join(repoPath, '.orca') - const localIssueCommandPath = join(localOrcaDir, 'issue-command') vi.mocked(fs.existsSync).mockImplementation( - (path) => path === gitignorePath || path === localOrcaDir + (path) => path === TEST_GITIGNORE_PATH || path === join(TEST_REPO_PATH, '.orca') ) vi.mocked(fs.readFileSync).mockImplementation((path) => { - if (path === gitignorePath) { + if (path === TEST_GITIGNORE_PATH) { return 'node_modules/\n' } return '' }) const { writeIssueCommand } = await import('./hooks') - writeIssueCommand(repoPath, 'local command') + writeIssueCommand(TEST_REPO_PATH, 'local command') expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - gitignorePath, + TEST_GITIGNORE_PATH, 'node_modules/\n.orca\n', 'utf-8' ) expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - localIssueCommandPath, + TEST_ISSUE_COMMAND_PATH, 'local command\n', 'utf-8' ) }) it('deletes the local override when the override is cleared', async () => { - const fs = await import('fs') - const repoPath = join('/test', 'repo') - const localIssueCommandPath = join(repoPath, '.orca', 'issue-command') const { writeIssueCommand } = await import('./hooks') - writeIssueCommand(repoPath, ' ') + const fs = await import('fs') + writeIssueCommand(TEST_REPO_PATH, ' ') - expect(vi.mocked(fs.rmSync)).toHaveBeenCalledWith(localIssueCommandPath, { + expect(vi.mocked(fs.rmSync)).toHaveBeenCalledWith(TEST_ISSUE_COMMAND_PATH, { force: true }) }) }) +describe('runner script builders', () => { + it('builds Windows runners for newline-heavy scripts without line-array splitting', async () => { + const { buildWindowsRunnerScript } = await import('./hooks') + const script = `${'\r\n'.repeat(10_000)}pnpm install\r\nnpm run build\n` + const splitSpy = vi.spyOn(String.prototype, 'split') + const replaceSpy = vi.spyOn(String.prototype, 'replace') + + try { + const result = buildWindowsRunnerScript(script) + + expect(result.startsWith('@echo off\r\nsetlocal EnableExtensions\r\n')).toBe(true) + expect(result).toContain('call pnpm install\r\nif errorlevel 1 exit /b %errorlevel%') + expect(result).toContain('call npm run build\r\nif errorlevel 1 exit /b %errorlevel%') + const usedLineSplit = splitSpy.mock.calls.some( + ([separator]) => + (typeof separator === 'string' && separator === '\n') || + (separator instanceof RegExp && separator.source === '\\r?\\n') + ) + const usedNewlineReplace = replaceSpy.mock.calls.some( + ([pattern]) => + pattern instanceof RegExp && (pattern.source === '\\r?\\n' || pattern.source === '\\r\\n') + ) + expect(usedLineSplit).toBe(false) + expect(usedNewlineReplace).toBe(false) + } finally { + splitSpy.mockRestore() + replaceSpy.mockRestore() + } + }) + + it('builds POSIX runners without regex-wide CRLF normalization', async () => { + const { buildPosixRunnerScript } = await import('./hooks') + const script = `${'echo setup\r\n'.repeat(10_000)}echo done` + const replaceSpy = vi.spyOn(String.prototype, 'replace') + + try { + const result = buildPosixRunnerScript(script) + + expect(result.startsWith('#!/usr/bin/env bash\nset -e\necho setup\n')).toBe(true) + expect(result.endsWith('echo done\n')).toBe(true) + const usedCrlfReplace = replaceSpy.mock.calls.some( + ([pattern]) => pattern instanceof RegExp && pattern.source === '\\r\\n' + ) + expect(usedCrlfReplace).toBe(false) + } finally { + replaceSpy.mockRestore() + } + }) +}) + describe('getEffectiveHooks', () => { // We need to dynamically import after mocking const makeRepo = (hookSettings?: { @@ -386,25 +431,21 @@ describe('getEffectiveHooks', () => { it("loads setup hooks from the target worktree's orca.yaml when a worktree path is provided", async () => { const fs = await import('fs') - const repoPath = join('/test', 'repo') - const worktreePath = join('/test', 'worktree') - const repoConfigPath = join(repoPath, 'orca.yaml') - const worktreeConfigPath = join(worktreePath, 'orca.yaml') vi.mocked(fs.existsSync).mockImplementation( - (path) => path === repoConfigPath || path === worktreeConfigPath + (path) => path === TEST_REPO_ORCA_YAML_PATH || path === TEST_WORKTREE_ORCA_YAML_PATH ) vi.mocked(fs.readFileSync).mockImplementation((path) => { - if (path === repoConfigPath) { + if (path === TEST_REPO_ORCA_YAML_PATH) { return 'scripts:\n setup: |\n echo old-version\n' } - if (path === worktreeConfigPath) { + if (path === TEST_WORKTREE_ORCA_YAML_PATH) { return 'scripts:\n setup: |\n echo new-version\n' } return '' }) const { getEffectiveHooks } = await import('./hooks') - const result = getEffectiveHooks(makeRepo(), worktreePath) + const result = getEffectiveHooks(makeRepo(), TEST_WORKTREE_PATH) expect(result).toEqual({ scripts: { diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 54ec922a57d..17a87757f52 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -147,13 +147,16 @@ const RECOGNIZED_ORCA_YAML_KEYS = new Set(['scripts', 'issueCommand', 'defaultTa export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean { try { const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8') - return content.split(/\r?\n/).some((line) => { + for (const line of iterateLfScriptLines(content)) { // Why: bare `key:` at end-of-line (no trailing space) is valid YAML for // a mapping with a block value on the next line. Match both forms so // newer keys like `futureFeature:\n nested` are still detected. const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/) - return m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1]) - }) + if (m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])) { + return true + } + } + return false } catch { return false } @@ -466,13 +469,12 @@ function getHookWslContext( } export function buildWindowsRunnerScript(script: string): string { - const lines = script.replace(/\r?\n/g, '\n').split('\n') - const runnerLines = ['@echo off', 'setlocal EnableExtensions'] + let runnerScript = '@echo off\r\nsetlocal EnableExtensions\r\n' - for (const rawLine of lines) { + for (const rawLine of iterateLfScriptLines(script)) { const command = rawLine.trim() if (!command) { - runnerLines.push('') + runnerScript += '\r\n' continue } @@ -481,11 +483,27 @@ export function buildWindowsRunnerScript(script: string): string { // to later lines, and plain newline-separated commands also keep running // after failures. Wrap each line in `call` and bail on non-zero exit codes // so the generated runner matches the fail-fast behavior of `set -e`. - runnerLines.push(`call ${command}`) - runnerLines.push('if errorlevel 1 exit /b %errorlevel%') + runnerScript += `call ${command}\r\nif errorlevel 1 exit /b %errorlevel%\r\n` } - return `${runnerLines.join('\r\n')}\r\n` + return runnerScript +} + +function* iterateLfScriptLines(script: string): Generator { + let lineStart = 0 + + for (let index = 0; index < script.length; index++) { + if (script.charCodeAt(index) !== 10) { + continue + } + const lineEnd = index > lineStart && script.charCodeAt(index - 1) === 13 ? index - 1 : index + yield script.slice(lineStart, lineEnd) + lineStart = index + 1 + } + + if (lineStart <= script.length) { + yield script.slice(lineStart) + } } export function createSetupRunnerScript( @@ -508,7 +526,28 @@ export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record< } export function buildPosixRunnerScript(script: string): string { - return `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n` + return `#!/usr/bin/env bash\nset -e\n${normalizeCrlfScriptLineEndings(script)}\n` +} + +function normalizeCrlfScriptLineEndings(script: string): string { + let crlfStart = script.indexOf('\r\n') + if (crlfStart === -1) { + return script + } + + let normalized = script.slice(0, crlfStart) + let chunkStart = crlfStart + 2 + normalized += '\n' + crlfStart = script.indexOf('\r\n', chunkStart) + + while (crlfStart !== -1) { + normalized += script.slice(chunkStart, crlfStart) + normalized += '\n' + chunkStart = crlfStart + 2 + crlfStart = script.indexOf('\r\n', chunkStart) + } + + return `${normalized}${script.slice(chunkStart)}` } export function createIssueCommandRunnerScript( @@ -543,9 +582,6 @@ function createWorktreeRunnerScript( // is 'win32'. Use bash scripts for WSL, .cmd for native Windows. const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro) const useWindowsFormat = process.platform === 'win32' && !wslWorktree - const normalizedScript = useWindowsFormat - ? script.replace(/\r?\n/g, '\r\n') - : script.replace(/\r\n/g, '\n') // Why: linked git worktrees use a `.git` file that points at the real gitdir, // so writing under `${worktreePath}/.git/...` fails. `git rev-parse --git-path` // resolves the actual per-worktree git storage path safely across platforms. @@ -565,9 +601,9 @@ function createWorktreeRunnerScript( mkdirSync(dirname(runnerScriptPath), { recursive: true }) if (useWindowsFormat) { - writeFileSync(runnerScriptPath, buildWindowsRunnerScript(normalizedScript), 'utf-8') + writeFileSync(runnerScriptPath, buildWindowsRunnerScript(script), 'utf-8') } else { - writeFileSync(runnerScriptPath, `#!/usr/bin/env bash\nset -e\n${normalizedScript}\n`, 'utf-8') + writeFileSync(runnerScriptPath, buildPosixRunnerScript(script), 'utf-8') // Why: chmod via UNC paths to WSL filesystem is supported by Windows and // sets the execute bit correctly inside WSL. chmodSync(runnerScriptPath, 0o755) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 46b361d925c..7d60da1799f 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -24,6 +24,10 @@ import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability' import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint' import { startSpan } from '../observability/tracer' import type { FeedbackDiagnosticBundleAttachment } from './feedback' +import { + assertClipboardTextWriteWithinLimit, + isClipboardTextWriteTooLargeError +} from '../../shared/clipboard-text' const inFlightSubmissions = new Set() const submittedReportIds = new Set() @@ -414,7 +418,16 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { clipboard.writeText(buildUncapturedCrashReportText(args?.notes)) return { ok: true as const } } - clipboard.writeText(formatCrashReportText(report, args?.notes)) + try { + clipboard.writeText( + assertClipboardTextWriteWithinLimit(formatCrashReportText(report, args?.notes)) + ) + } catch (error) { + if (isClipboardTextWriteTooLargeError(error)) { + return { ok: false as const, error: 'Crash diagnostics are too large to copy safely.' } + } + throw error + } return { ok: true as const } } ) diff --git a/src/main/ipc/dropped-path-resolution.test.ts b/src/main/ipc/dropped-path-resolution.test.ts index 2d5c16c37f1..4c64515d5af 100644 --- a/src/main/ipc/dropped-path-resolution.test.ts +++ b/src/main/ipc/dropped-path-resolution.test.ts @@ -1,25 +1,60 @@ import { describe, expect, it } from 'vitest' +import { PASTE_PAYLOAD_CORPUS } from '../../shared/paste-payload-corpus' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' -describe('resolveLocalDroppedPathsForAgent', () => { - it('translates dropped Windows paths for local WSL worktrees', () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - expect( - resolveLocalDroppedPathsForAgent( - [ - 'C:\\Users\\alice\\Desktop\\notes.txt', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\README.md' - ], - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' - ) - ).toEqual(['/mnt/c/Users/alice/Desktop/notes.txt', '/home/alice/repo/README.md']) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } +function getPastePayloadCorpusText(name: string): string { + const entry = PASTE_PAYLOAD_CORPUS.find((item) => item.name === name) + if (!entry) { + throw new Error(`Missing paste payload corpus case: ${name}`) + } + return entry.text +} + +function withWin32Platform(callback: () => T): T { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + return callback() + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) } + } +} + +describe('resolveLocalDroppedPathsForAgent', () => { + it('translates only target-readable Windows paths for local WSL worktrees', () => { + const windowsPath = getPastePayloadCorpusText('Windows path with spaces') + const sameDistroWslPath = getPastePayloadCorpusText('WSL UNC path') + const otherDistroWslPath = '\\\\wsl.localhost\\Debian\\home\\user\\repo' + const uncPath = getPastePayloadCorpusText('UNC path') + const posixPath = getPastePayloadCorpusText('POSIX path with spaces') + + expect( + withWin32Platform(() => + resolveLocalDroppedPathsForAgent( + [windowsPath, sameDistroWslPath, otherDistroWslPath, uncPath, posixPath], + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\repo' + ) + ) + ).toEqual([ + '/mnt/c/Users/Name/My Project/file.txt', + '/home/user/repo', + otherDistroWslPath, + uncPath, + posixPath + ]) + }) + + it('translates same-distro legacy WSL UNC paths case-insensitively', () => { + expect( + withWin32Platform(() => + resolveLocalDroppedPathsForAgent( + ['\\\\wsl$\\ubuntu-24.04\\home\\user\\repo\\README.md'], + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\repo' + ) + ) + ).toEqual(['/home/user/repo/README.md']) }) it('leaves dropped paths unchanged for non-WSL worktrees', () => { diff --git a/src/main/ipc/dropped-path-resolution.ts b/src/main/ipc/dropped-path-resolution.ts index 5ef94ca13de..926b9b5b315 100644 --- a/src/main/ipc/dropped-path-resolution.ts +++ b/src/main/ipc/dropped-path-resolution.ts @@ -3,5 +3,22 @@ import { parseWslPath, toLinuxPath } from '../wsl' export function resolveLocalDroppedPathsForAgent(paths: string[], worktreePath: string): string[] { // Why: a local WSL PTY runs inside Linux, so Windows drop paths must be // rewritten to paths the shell and agent can read. - return parseWslPath(worktreePath) ? paths.map((droppedPath) => toLinuxPath(droppedPath)) : paths + const targetWsl = parseWslPath(worktreePath) + return targetWsl + ? paths.map((droppedPath) => resolveDroppedPathForTargetWsl(droppedPath, targetWsl.distro)) + : paths +} + +function resolveDroppedPathForTargetWsl(droppedPath: string, targetDistro: string): string { + const droppedWsl = parseWslPath(droppedPath) + if (droppedWsl) { + // Why: WSL UNC paths are only Linux-native inside their own distro. + // Rewriting another distro would paste a plausible but wrong path. + return isSameWslDistro(droppedWsl.distro, targetDistro) ? droppedWsl.linuxPath : droppedPath + } + return toLinuxPath(droppedPath) +} + +function isSameWslDistro(left: string, right: string): boolean { + return left.localeCompare(right, undefined, { sensitivity: 'accent' }) === 0 } diff --git a/src/main/ipc/pty-startup-barrier-ordering.test.ts b/src/main/ipc/pty-startup-barrier-ordering.test.ts index 38926946639..78f7477f468 100644 --- a/src/main/ipc/pty-startup-barrier-ordering.test.ts +++ b/src/main/ipc/pty-startup-barrier-ordering.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it } from 'vitest' describe('PTY startup barrier ordering', () => { it('waits for local startup before resolving the provider for runtime and renderer spawns', () => { - const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8').replaceAll( - '\r\n', + const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8').replace( + /\r\n?/g, '\n' ) const runtimeSpawnStart = source.indexOf('spawn: async (args) => {') diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index e27685f05b0..1ce07eabc5b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2,11 +2,20 @@ one focused file because the registration helper is stateful and each spawn-path assertion reuses the same mocked IPC and node-pty harness. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { delimiter, join } from 'node:path' +import { delimiter, join, posix } from 'node:path' +import { + TERMINAL_INPUT_CHUNK_MAX_BYTES, + TERMINAL_INPUT_MAX_BYTES +} from '../../shared/terminal-input' +import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it -const expectedOmpStatusExtension = '/tmp/default-omp-agent/extensions/orca-agent-status.ts' +const expectedOmpStatusExtension = posix.join( + '/tmp/default-omp-agent', + 'extensions', + 'orca-agent-status.ts' +) const expectedAttributionShimDir = join( '/tmp/orca-user-data', 'orca-terminal-attribution', @@ -210,6 +219,10 @@ describe('registerPtyHandlers', () => { removeListener: vi.fn() } } + const mainWindowIpcEvent = { sender: mainWindow.webContents } + const foreignWindowIpcEvent = { + sender: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } + } const savedOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR const savedOrcaOpenCodeConfigDir = process.env.ORCA_OPENCODE_CONFIG_DIR @@ -222,8 +235,15 @@ describe('registerPtyHandlers', () => { const savedOrcaOmpSourceAgentDir = process.env.ORCA_OMP_SOURCE_AGENT_DIR const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS + const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform') beforeEach(() => { + // Why: most PTY spawn tests assert POSIX shell behavior; Windows-specific + // cases opt into win32 explicitly below. + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'darwin' + }) delete process.env.OPENCODE_CONFIG_DIR delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR delete process.env.ORCA_OPENCODE_CONFIG_DIR @@ -316,6 +336,9 @@ describe('registerPtyHandlers', () => { vi.useRealTimers() unregisterSshPtyProvider('ssh-1') setLocalPtyProvider(new LocalPtyProvider()) + if (savedProcessPlatform) { + Object.defineProperty(process, 'platform', savedProcessPlatform) + } if (savedOpenCodeConfigDir !== undefined) { process.env.OPENCODE_CONFIG_DIR = savedOpenCodeConfigDir } else { @@ -3180,7 +3203,9 @@ describe('registerPtyHandlers', () => { return call[1] as (event: unknown, args: unknown) => void } - expect(() => listenerFor('pty:write')(null, { id: 'remote-pty', data: 'x' })).not.toThrow() + expect(() => + listenerFor('pty:write')(mainWindowIpcEvent, { id: 'remote-pty', data: 'x' }) + ).not.toThrow() expect(() => listenerFor('pty:resize')(null, { id: 'remote-pty', cols: 100, rows: 30 }) ).not.toThrow() @@ -3864,7 +3889,7 @@ describe('registerPtyHandlers', () => { expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled() expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled() expect(remoteShutdown).not.toHaveBeenCalled() - getPtyWriteListener()(null, { + getPtyWriteListener()(mainWindowIpcEvent, { id: 'ssh:ssh-reattach-fail@@relay-pty', data: 'echo should-not-route' }) @@ -3965,7 +3990,7 @@ describe('registerPtyHandlers', () => { expect(store.persistPtyBinding).not.toHaveBeenCalled() expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId) expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId) - getPtyWriteListener()(null, { id: appPtyId, data: 'echo nope' }) + getPtyWriteListener()(mainWindowIpcEvent, { id: appPtyId, data: 'echo nope' }) expect(remoteWrite).not.toHaveBeenCalled() } finally { deletePtyOwnership(appPtyId) @@ -4958,7 +4983,7 @@ describe('registerPtyHandlers', () => { })) as { id: string } const writeListener = getPtyWriteListener() - writeListener(null, { + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) @@ -4992,7 +5017,7 @@ describe('registerPtyHandlers', () => { }) const writeListener = getPtyWriteListener() - writeListener(null, { + writeListener(mainWindowIpcEvent, { id: 'missing-pty', data: 'a' }) @@ -5017,7 +5042,7 @@ describe('registerPtyHandlers', () => { })) as { id: string } const writeListener = getPtyWriteListener() - writeListener(null, { + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) @@ -5051,7 +5076,7 @@ describe('registerPtyHandlers', () => { })) as { id: string } const writeListener = getPtyWriteListener() - writeListener(null, { + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) @@ -5088,7 +5113,7 @@ describe('registerPtyHandlers', () => { })) as { id: string } const writeListener = getPtyWriteListener() - writeListener(null, { + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) @@ -5126,7 +5151,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData(pendingOutput) expect(mainWindow.webContents.send).not.toHaveBeenCalled() - writeListener(null, { + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) @@ -5373,7 +5398,7 @@ describe('registerPtyHandlers', () => { expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512) expect(vi.getTimerCount()).toBe(0) - writeListener(null, { + writeListener(mainWindowIpcEvent, { id: interactiveSpawn.id, data: 'a' }) @@ -5388,7 +5413,7 @@ describe('registerPtyHandlers', () => { const reservePrefix = '\x1b[20;2H' const reserveChunk = `${reservePrefix}${'r'.repeat(16 * 1024 - reservePrefix.length)}` for (let index = 0; index < 16; index++) { - writeListener(null, { + writeListener(mainWindowIpcEvent, { id: interactiveSpawn.id, data: 'a' }) @@ -5396,7 +5421,7 @@ describe('registerPtyHandlers', () => { } expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529) - writeListener(null, { + writeListener(mainWindowIpcEvent, { id: interactiveSpawn.id, data: 'a' }) @@ -5561,7 +5586,7 @@ describe('registerPtyHandlers', () => { })) as { id: string } const writeListener = getPtyWriteListener() - writeListener(null, { + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) @@ -5674,10 +5699,15 @@ describe('registerPtyHandlers', () => { rows: 24 })) as { id: string } - expect(handlers.get('pty:writeAccepted')!(null, { id: result.id, data: '\x03' })).toBe(true) + expect( + handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { + id: result.id, + data: '\x03' + }) + ).toBe(true) expect(mockProc.proc.write).toHaveBeenCalledWith('\x03') expect( - handlers.get('pty:writeAccepted')!(null, { + handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { id: 'missing-pty-for-write-ack', data: '\x03' }) @@ -5685,6 +5715,95 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).toHaveBeenCalledTimes(1) }) + it('rejects malformed and cross-window pty write IPC before provider writes', async () => { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + const write = getPtyWriteListener() as (event: unknown, args: unknown) => void + const writeAccepted = handlers.get('pty:writeAccepted')! as ( + event: unknown, + args: unknown + ) => unknown + + write(mainWindowIpcEvent, null) + write(mainWindowIpcEvent, { id: '', data: 'x' }) + write(mainWindowIpcEvent, { id: result.id, data: 1 }) + write(foreignWindowIpcEvent, { id: result.id, data: 'x' }) + + expect(writeAccepted(mainWindowIpcEvent, null)).toBe(false) + expect(writeAccepted(mainWindowIpcEvent, { id: '', data: 'x' })).toBe(false) + expect(writeAccepted(mainWindowIpcEvent, { id: result.id, data: 1 })).toBe(false) + expect(writeAccepted(foreignWindowIpcEvent, { id: result.id, data: 'x' })).toBe(false) + expect(mockProc.proc.write).not.toHaveBeenCalled() + }) + + it('chunks large acknowledged pty writes before provider writes', async () => { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('') + + await expect( + handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { id: result.id, data: text }) + ).resolves.toBe(true) + + expect(mockProc.proc.write).toHaveBeenNthCalledWith( + 1, + 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) + ) + expect(mockProc.proc.write).toHaveBeenNthCalledWith(2, 'tail') + }) + + it('yields while validating accepted large acknowledged pty writes before provider writes', async () => { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + + vi.useFakeTimers() + const writeResult = handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { + id: result.id, + data: text + }) + + expect(writeResult).toBeInstanceOf(Promise) + expect(mockProc.proc.write).not.toHaveBeenCalled() + + await vi.runAllTimersAsync() + await expect(writeResult).resolves.toBe(true) + expect(mockProc.proc.write.mock.calls.map(([chunk]) => chunk).join('')).toBe(text) + }) + + it('rejects oversized acknowledged pty writes before provider writes', async () => { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + + expect( + handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { + id: result.id, + data: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) + }) + ).toBe(false) + expect(mockProc.proc.write).not.toHaveBeenCalled() + }) + it('seeds headless terminal state with cold-restore cwd metadata', async () => { const oscLinks = [{ row: 0, startCol: 0, endCol: 8, uri: 'https://example.com/restored' }] const coldRestore = { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c6d22cc3240..ada5767afde 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5,7 +5,14 @@ boundary. Splitting it by line count would scatter tightly coupled terminal process behavior across files without a cleaner ownership seam. */ import { join, delimiter } from 'path' import { randomUUID } from 'crypto' -import { type BrowserWindow, type WebContents, ipcMain, app } from 'electron' +import { + type BrowserWindow, + type IpcMainEvent, + type IpcMainInvokeEvent, + type WebContents, + ipcMain, + app +} from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' @@ -50,6 +57,10 @@ import { launchSourceSchema, requestKindSchema } from '../../shared/telemetry-events' +import { + isTerminalInputTooLargeWithDeferredMeasurement, + iterateTerminalInputChunks +} from '../../shared/terminal-input' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure' import { readShellStartupEnvVar } from '../pty/shell-startup-env' @@ -2656,7 +2667,85 @@ export function registerPtyHandlers( } ) - const writePtyInput = (args: { id: string; data: string }): boolean => { + const writePtyProviderInputWithinLimit = ( + provider: IPtyProvider, + id: string, + data: string + ): boolean | Promise => { + const chunks = iterateTerminalInputChunks(data) + const first = chunks.next() + if (first.done) { + provider.write(id, data) + return true + } + const second = chunks.next() + if (second.done) { + provider.write(id, first.value) + return true + } + return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) + } + + const writePtyProviderInput = ( + provider: IPtyProvider, + id: string, + data: string + ): boolean | Promise => { + try { + const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) + if (typeof tooLarge === 'boolean') { + return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) + } + return tooLarge + .then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data))) + .catch(() => false) + } catch { + return false + } + } + + const writePtyProviderInputChunks = async ( + provider: IPtyProvider, + id: string, + chunks: Iterator, + firstChunk: string, + secondChunk: string + ): Promise => { + try { + let chunk: IteratorResult = { done: false, value: firstChunk } + let nextChunk: IteratorResult = { done: false, value: secondChunk } + while (!chunk.done) { + provider.write(id, chunk.value) + if (!nextChunk.done) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + chunk = nextChunk + nextChunk = chunks.next() + } + return true + } catch { + return false + } + } + + type PtyWritePayload = { id: string; data: string } + + const isPtyWritePayload = (value: unknown): value is PtyWritePayload => + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.length > 0 && + typeof (value as { data?: unknown }).data === 'string' + + const isPtyWriteEventFromMainWindow = ( + event: IpcMainEvent | IpcMainInvokeEvent, + mainWebContents: WebContents + ): boolean => + event.sender === mainWebContents && + !mainWindow.isDestroyed() && + !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) + + const writePtyInput = (args: PtyWritePayload): boolean | Promise => { // Why: defense-in-depth for the mobile-presence lock. The renderer's // xterm.onData guard already drops desktop keystrokes when mobile is // driving, but a stale view between the main-side state flip and the @@ -2674,14 +2763,13 @@ export function registerPtyHandlers( const now = performance.now() lastInputAtByPty.set(args.id, now) interactiveOutputCharsByPty.set(args.id, 0) - provider.write(args.id, args.data) - return true + return writePtyProviderInput(provider, args.id, args.data) } catch { return false } } - const writePtyInputAccepted = (args: { id: string; data: string }): boolean => { + const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise => { if (runtime?.getDriver(args.id).kind === 'mobile') { return false } @@ -2700,17 +2788,22 @@ export function registerPtyHandlers( const now = performance.now() lastInputAtByPty.set(args.id, now) interactiveOutputCharsByPty.set(args.id, 0) - provider.write(args.id, args.data) - return true + return writePtyProviderInput(provider, args.id, args.data) } catch { return false } } - ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => { + ipcMain.on('pty:write', (event, args: unknown) => { + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + return + } writePtyInput(args) }) - ipcMain.handle('pty:writeAccepted', (_event, args: { id: string; data: string }): boolean => { + ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + return false + } return writePtyInputAccepted(args) }) diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 7c55815643f..a2db899b682 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -24,6 +24,7 @@ const { registerPetHandlersMock, registerSessionHandlersMock, registerUIHandlersMock, + setTrustedUIRendererWebContentsIdMock, registerFilesystemHandlersMock, registerRuntimeHandlersMock, registerRuntimeEnvironmentHandlersMock, @@ -33,6 +34,7 @@ const { registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, registerClipboardHandlersMock, + setTrustedClipboardRendererWebContentsIdMock, registerUpdaterHandlersMock, registerRateLimitHandlersMock, registerBrowserHandlersMock, @@ -73,6 +75,7 @@ const { registerPetHandlersMock: vi.fn(), registerSessionHandlersMock: vi.fn(), registerUIHandlersMock: vi.fn(), + setTrustedUIRendererWebContentsIdMock: vi.fn(), registerFilesystemHandlersMock: vi.fn(), registerRuntimeHandlersMock: vi.fn(), registerRuntimeEnvironmentHandlersMock: vi.fn(), @@ -82,6 +85,7 @@ const { registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), + setTrustedClipboardRendererWebContentsIdMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), registerRateLimitHandlersMock: vi.fn(), registerBrowserHandlersMock: vi.fn(), @@ -207,7 +211,8 @@ vi.mock('./session', () => ({ })) vi.mock('./ui', () => ({ - registerUIHandlers: registerUIHandlersMock + registerUIHandlers: registerUIHandlersMock, + setTrustedUIRendererWebContentsId: setTrustedUIRendererWebContentsIdMock })) vi.mock('./emulator-frame-stream', () => ({ @@ -259,7 +264,8 @@ vi.mock('../window/attach-main-window-services', () => ({ })) vi.mock('../window/clipboard-ipc-handlers', () => ({ - registerClipboardHandlers: registerClipboardHandlersMock + registerClipboardHandlers: registerClipboardHandlersMock, + setTrustedClipboardRendererWebContentsId: setTrustedClipboardRendererWebContentsIdMock })) vi.mock('./browser', () => ({ @@ -313,6 +319,7 @@ describe('registerCoreHandlers', () => { registerPetHandlersMock.mockReset() registerSessionHandlersMock.mockReset() registerUIHandlersMock.mockReset() + setTrustedUIRendererWebContentsIdMock.mockReset() registerFilesystemHandlersMock.mockReset() registerRuntimeHandlersMock.mockReset() registerRuntimeEnvironmentHandlersMock.mockReset() @@ -322,6 +329,7 @@ describe('registerCoreHandlers', () => { registerAgentTrustHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() + setTrustedClipboardRendererWebContentsIdMock.mockReset() registerUpdaterHandlersMock.mockReset() registerRateLimitHandlersMock.mockReset() registerBrowserHandlersMock.mockReset() @@ -415,6 +423,8 @@ describe('registerCoreHandlers', () => { expect(registerClipboardHandlersMock).toHaveBeenCalled() expect(registerUpdaterHandlersMock).toHaveBeenCalled() expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(null) + expect(setTrustedClipboardRendererWebContentsIdMock).toHaveBeenCalledWith(null) + expect(setTrustedUIRendererWebContentsIdMock).toHaveBeenCalledWith(null) expect(registerBrowserHandlersMock).toHaveBeenCalled() expect(registerFilesystemWatcherHandlersMock).toHaveBeenCalled() expect(registerSpeechHandlersMock).toHaveBeenCalledWith(store) @@ -448,6 +458,8 @@ describe('registerCoreHandlers', () => { // Web contents ID should always be updated expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(42) + expect(setTrustedClipboardRendererWebContentsIdMock).toHaveBeenCalledWith(42) + expect(setTrustedUIRendererWebContentsIdMock).toHaveBeenCalledWith(42) // IPC handlers should NOT be registered again expect(registerCliHandlersMock).not.toHaveBeenCalled() expect(registerPreflightHandlersMock).not.toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index b496659e634..fd55b140421 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -42,7 +42,7 @@ import { registerTelemetryHandlers } from './telemetry' import { registerBrowserHandlers } from './browser' import { registerShellHandlers } from './shell' import { registerPetHandlers } from './pet' -import { registerUIHandlers } from './ui' +import { registerUIHandlers, setTrustedUIRendererWebContentsId } from './ui' import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream' import { registerSpeechHandlers } from './speech' import { registerCodexAccountHandlers } from './codex-accounts' @@ -50,7 +50,10 @@ import { registerAgentHookHandlers } from './agent-hooks' import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' import { registerUpdaterHandlers } from '../window/attach-main-window-services' -import { registerClipboardHandlers } from '../window/clipboard-ipc-handlers' +import { + registerClipboardHandlers, + setTrustedClipboardRendererWebContentsId +} from '../window/clipboard-ipc-handlers' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' import type { OpenCodeUsageStore } from '../opencode-usage/store' @@ -92,6 +95,8 @@ export function registerCoreHandlers( // if a channel is registered twice, so we guard to register only once and // just update the per-window web-contents ID on subsequent calls. setTrustedBrowserRendererWebContentsId(mainWindowWebContentsId) + setTrustedClipboardRendererWebContentsId(mainWindowWebContentsId) + setTrustedUIRendererWebContentsId(mainWindowWebContentsId) setAgentBrowserBridgeRef(runtime.getAgentBrowserBridge()) if (registered) { return diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index a221b941151..148d532716d 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -431,6 +431,55 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) }) + it('ssh:connect exposes the detected remote platform in public state', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Windows Server', + host: 'windows.example.com', + port: 22, + username: 'deploy' + } + const hostPlatform = { + relayPlatform: 'win32-x64', + os: 'win32', + arch: 'x64', + pathFlavor: 'windows', + commandDialect: 'powershell', + pathSeparator: '\\', + pathDelimiter: ';' + } + mockDeployAndLaunchRelay.mockResolvedValueOnce({ + transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, + hostPlatform + }) + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + remotePlatform: 'win32' + }) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + remotePlatform: 'win32' + } + }) + }) + it('surfaces relay channel loss while the SSH connection remains alive', async () => { vi.useFakeTimers() const target: SshTarget = { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index e2ea19d1f74..6b61a97fba7 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -155,10 +155,18 @@ function broadcastSshState( ): void { const win = getMainWindow() if (win && !win.isDestroyed()) { - win.webContents.send('ssh:state-changed', { targetId, state }) + win.webContents.send('ssh:state-changed', { + targetId, + state: withSshRemotePlatform(targetId, state) + }) } } +function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { + const remotePlatform = activeSessions.get(targetId)?.getHostPlatform()?.os + return remotePlatform ? { ...state, remotePlatform } : state +} + function publishRelayOverride( getMainWindow: () => BrowserWindow | null, targetId: string, @@ -166,7 +174,7 @@ function publishRelayOverride( error: string | null, reconnectAttempt: number ): void { - const state: SshConnectionState = { targetId, status, error, reconnectAttempt } + const state = withSshRemotePlatform(targetId, { targetId, status, error, reconnectAttempt }) relayStateOverrides.set(targetId, state) broadcastSshState(getMainWindow, targetId, state) } @@ -176,7 +184,8 @@ function clearRelayStateOverride(targetId: string): void { } function getPublicSshState(targetId: string): SshConnectionState | undefined { - return relayStateOverrides.get(targetId) ?? connectionManager!.getState(targetId) ?? undefined + const state = relayStateOverrides.get(targetId) ?? connectionManager!.getState(targetId) + return state ? withSshRemotePlatform(targetId, state) : undefined } function broadcastPortForwards(getMainWindow: () => BrowserWindow | null, targetId: string): void { @@ -756,12 +765,12 @@ export function registerSshHandlers( clearRelayStateOverride(targetId) win.webContents.send('ssh:state-changed', { targetId, - state: { + state: withSshRemotePlatform(targetId, { targetId, status: 'connected', error: null, reconnectAttempt: 0 - } + }) }) } } catch (err) { diff --git a/src/main/ipc/ui.test.ts b/src/main/ipc/ui.test.ts new file mode 100644 index 00000000000..4435c1c77e3 --- /dev/null +++ b/src/main/ipc/ui.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { fromWebContentsMock, getAllWindowsMock, handleMock, onMock, removeAllListenersMock } = + vi.hoisted(() => ({ + fromWebContentsMock: vi.fn(), + getAllWindowsMock: vi.fn(() => []), + handleMock: vi.fn(), + onMock: vi.fn(), + removeAllListenersMock: vi.fn() + })) + +vi.mock('electron', () => ({ + BrowserWindow: { + fromWebContents: fromWebContentsMock, + getAllWindows: getAllWindowsMock + }, + ipcMain: { + handle: handleMock, + on: onMock, + removeAllListeners: removeAllListenersMock + } +})) + +import { + clearTrustedUIRendererWebContentsId, + registerUIHandlers, + setTrustedUIRendererWebContentsId +} from './ui' + +function makeStore() { + return { + onUIChanged: vi.fn(), + getUI: vi.fn(() => ({})), + updateUI: vi.fn(), + recordFeatureInteraction: vi.fn() + } +} + +function makeUIEvent(senderOverrides: Record = {}): { + sender: Record +} { + return { + sender: { + id: 17, + getType: () => 'window', + getURL: () => 'file:///orca/index.html', + isDestroyed: () => false, + ...senderOverrides + } + } +} + +function getNativePasteHandler(): + | ((event: ReturnType, options?: { mode?: unknown }) => void) + | undefined { + return onMock.mock.calls.find(([channel]) => channel === 'ui:performNativePaste')?.[1] +} + +describe('registerUIHandlers', () => { + beforeEach(() => { + vi.stubEnv('ELECTRON_RENDERER_URL', '') + fromWebContentsMock.mockReset() + getAllWindowsMock.mockReset() + getAllWindowsMock.mockReturnValue([]) + handleMock.mockReset() + onMock.mockReset() + removeAllListenersMock.mockReset() + setTrustedUIRendererWebContentsId(null) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('routes native paste fallback to the requesting webContents only', () => { + const paste = vi.fn() + const pasteAndMatchStyle = vi.fn() + const event = makeUIEvent() + const sender = event.sender + setTrustedUIRendererWebContentsId(17) + fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } }) + + registerUIHandlers(makeStore() as never) + + expect(removeAllListenersMock).toHaveBeenCalledWith('ui:performNativePaste') + const nativePasteHandler = getNativePasteHandler() + nativePasteHandler?.(event) + nativePasteHandler?.(event, { mode: 'paste-and-match-style' }) + + expect(fromWebContentsMock).toHaveBeenCalledWith(sender) + expect(paste).toHaveBeenCalledTimes(1) + expect(pasteAndMatchStyle).toHaveBeenCalledTimes(1) + }) + + it('ignores native paste fallback from stale or browser senders', () => { + const paste = vi.fn() + const pasteAndMatchStyle = vi.fn() + setTrustedUIRendererWebContentsId(17) + fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } }) + + registerUIHandlers(makeStore() as never) + + const nativePasteHandler = getNativePasteHandler() + nativePasteHandler?.(makeUIEvent({ id: 42 })) + nativePasteHandler?.(makeUIEvent({ getType: () => 'webview' })) + + expect(fromWebContentsMock).not.toHaveBeenCalled() + expect(paste).not.toHaveBeenCalled() + expect(pasteAndMatchStyle).not.toHaveBeenCalled() + }) + + it('ignores native paste fallback from destroyed senders', () => { + const paste = vi.fn() + const pasteAndMatchStyle = vi.fn() + fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } }) + + registerUIHandlers(makeStore() as never) + + const nativePasteHandler = getNativePasteHandler() + nativePasteHandler?.(makeUIEvent({ isDestroyed: () => true })) + + expect(fromWebContentsMock).not.toHaveBeenCalled() + expect(paste).not.toHaveBeenCalled() + expect(pasteAndMatchStyle).not.toHaveBeenCalled() + }) + + it('rejects packaged file-url senders until the main window id is registered', () => { + const paste = vi.fn() + const pasteAndMatchStyle = vi.fn() + const event = makeUIEvent() + fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } }) + + registerUIHandlers(makeStore() as never) + + getNativePasteHandler()?.(event) + + expect(fromWebContentsMock).not.toHaveBeenCalled() + expect(paste).not.toHaveBeenCalled() + expect(pasteAndMatchStyle).not.toHaveBeenCalled() + }) + + it('clears the trusted renderer id without clearing a newer window id', () => { + const paste = vi.fn() + const pasteAndMatchStyle = vi.fn() + setTrustedUIRendererWebContentsId(17) + clearTrustedUIRendererWebContentsId(42) + fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } }) + + registerUIHandlers(makeStore() as never) + + getNativePasteHandler()?.(makeUIEvent()) + + expect(paste).toHaveBeenCalledTimes(1) + + clearTrustedUIRendererWebContentsId(17) + fromWebContentsMock.mockClear() + paste.mockClear() + + getNativePasteHandler()?.(makeUIEvent()) + + expect(fromWebContentsMock).not.toHaveBeenCalled() + expect(paste).not.toHaveBeenCalled() + }) + + it('allows native paste fallback only from the configured dev renderer origin', () => { + const paste = vi.fn() + const pasteAndMatchStyle = vi.fn() + const event = makeUIEvent({ getURL: () => 'http://localhost:5173/workspace' }) + vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173') + fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } }) + + registerUIHandlers(makeStore() as never) + + const nativePasteHandler = getNativePasteHandler() + nativePasteHandler?.(event) + + expect(fromWebContentsMock).toHaveBeenCalledWith(event.sender) + expect(paste).toHaveBeenCalledTimes(1) + + fromWebContentsMock.mockClear() + paste.mockClear() + nativePasteHandler?.(makeUIEvent({ getURL: () => 'http://127.0.0.1:5173/workspace' })) + nativePasteHandler?.(makeUIEvent({ getURL: () => 'file:///orca/index.html' })) + nativePasteHandler?.(makeUIEvent({ getURL: () => 'not a url' })) + + expect(fromWebContentsMock).not.toHaveBeenCalled() + expect(paste).not.toHaveBeenCalled() + expect(pasteAndMatchStyle).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index d2746aa3e10..efe6e300f85 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -1,8 +1,20 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { BrowserWindow, ipcMain, type WebContents } from 'electron' import type { Store } from '../persistence' import type { PersistedUIState } from '../../shared/types' import { isFeatureInteractionId } from '../../shared/feature-interactions' +let trustedUIRendererWebContentsId: number | null = null + +export function setTrustedUIRendererWebContentsId(webContentsId: number | null): void { + trustedUIRendererWebContentsId = webContentsId +} + +export function clearTrustedUIRendererWebContentsId(webContentsId: number): void { + if (trustedUIRendererWebContentsId === webContentsId) { + trustedUIRendererWebContentsId = null + } +} + export function registerUIHandlers(store: Store): void { // Why: UI view-state is shared between the desktop renderer and mobile (ui.set // RPC). Broadcast every change so the desktop re-hydrates when mobile (or @@ -29,4 +41,41 @@ export function registerUIHandlers(store: Store): void { } return store.recordFeatureInteraction(id) }) + + ipcMain.removeAllListeners('ui:performNativePaste') + ipcMain.on('ui:performNativePaste', (event, options?: { mode?: unknown }) => { + if (!isTrustedUIRenderer(event.sender)) { + return + } + // Why: coordinated renderer paste falls back here only after no Orca owner + // claims the app-menu action; paste back into the requesting window only. + const webContents = BrowserWindow.fromWebContents(event.sender)?.webContents + if (options?.mode === 'paste-and-match-style') { + webContents?.pasteAndMatchStyle() + return + } + webContents?.paste() + }) +} + +function isTrustedUIRenderer(sender: WebContents): boolean { + if (sender.isDestroyed() || sender.getType() !== 'window') { + return false + } + if (trustedUIRendererWebContentsId != null) { + return sender.id === trustedUIRendererWebContentsId + } + + const senderUrl = sender.getURL() + if (process.env.ELECTRON_RENDERER_URL) { + try { + return new URL(senderUrl).origin === new URL(process.env.ELECTRON_RENDERER_URL).origin + } catch { + return false + } + } + + // Why: packaged fallback must be tied to the created main window id, not any + // file:// document that can obtain this IPC channel. + return false } diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts index 583e3c174a7..24234aaa0ab 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -183,6 +183,35 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { expect(removeCalls(exec)).toEqual([]) }) + it('checks branch config without line-array or whitespace-regex splitting', async () => { + const exec = makeExec({ + branchConfig: [ + `branch.contributor/fix.pushRemote\tunused`, + ` branch.contributor/fix.remote ${FORK_REMOTE} ` + ].join('\r\n') + }) + const splitSpy = vi.spyOn(String.prototype, 'split') + try { + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget(), + storeOf({ 'repo-1::/wt/a': forkTarget() }), + exec + ) + const usedUnboundedOutputSplit = splitSpy.mock.calls.some(([separator]) => { + return ( + separator instanceof RegExp && + (separator.source === '\\r?\\n' || separator.source === '\\s+') + ) + }) + expect(removeCalls(exec)).toEqual([]) + expect(usedUnboundedOutputSplit).toBe(false) + } finally { + splitSpy.mockRestore() + } + }) + it('keeps the remote when its URL no longer matches the fork (repurposed by the user)', async () => { const exec = makeExec({ getUrl: 'git@github.com:someone-else/orca.git' }) await cleanupUnusedWorktreePushTargetRemoteWithExec( diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index b941efb9d22..d7fdc7a04be 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -7,6 +7,7 @@ import type { Store } from '../persistence' import type { GitPushTarget } from '../../shared/types' import { parseGitHubOwnerRepo } from '../github/gh-utils' import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' export type GitRemoteExec = ( args: string[], @@ -62,19 +63,46 @@ async function hasBranchConfigUsingRemote( ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], repoPath ) - return stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .some((line) => { - const value = line.split(/\s+/).slice(1).join(' ') - return value === target.remoteName || value === target.remoteUrl - }) + // Why: git config output can be large; avoid materializing line/split arrays here. + for (const line of iterateProcessOutputLines(stdout)) { + const value = readBranchRemoteConfigValue(line) + if (value === target.remoteName || value === target.remoteUrl) { + return true + } + } + return false } catch { return false } } +function readBranchRemoteConfigValue(line: string): string | null { + let index = 0 + while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) { + index += 1 + } + while (index < line.length && !isBranchConfigSeparator(line.charCodeAt(index))) { + index += 1 + } + while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) { + index += 1 + } + if (index >= line.length) { + return null + } + + const valueStart = index + let valueEnd = line.length + while (valueEnd > valueStart && isBranchConfigSeparator(line.charCodeAt(valueEnd - 1))) { + valueEnd -= 1 + } + return valueStart < valueEnd ? line.slice(valueStart, valueEnd) : null +} + +function isBranchConfigSeparator(code: number): boolean { + return code === 32 || (code >= 9 && code <= 13) +} + // Exported for unit tests: the `execGit` seam lets tests drive the multi-fork // cleanup matrix without touching a real repo. export async function cleanupUnusedWorktreePushTargetRemoteWithExec( diff --git a/src/main/linear/issue-context-errors.test.ts b/src/main/linear/issue-context-errors.test.ts new file mode 100644 index 00000000000..215bbf2439c --- /dev/null +++ b/src/main/linear/issue-context-errors.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import { sanitizeLinearErrorMessage } from './issue-context-errors' + +describe('sanitizeLinearErrorMessage', () => { + it('removes stack frames without regex line splitting', () => { + const splitSpy = vi.spyOn(String.prototype, 'split') + try { + expect( + sanitizeLinearErrorMessage('Linear request failed\r\n at request (sdk.js:10:2)') + ).toBe('Linear request failed') + const usedStackSplit = splitSpy.mock.calls.some( + ([separator]) => separator instanceof RegExp && separator.source === '\\r?\\n\\s+at\\s+' + ) + expect(usedStackSplit).toBe(false) + } finally { + splitSpy.mockRestore() + } + }) + + it('redacts sensitive Linear payloads after trimming stack frames', () => { + expect( + sanitizeLinearErrorMessage( + 'Request failed authorization: Bearer secret-token\n at request (sdk.js:10:2)' + ) + ).toBe('Request failed authorization: Bearer [REDACTED]') + }) +}) diff --git a/src/main/linear/issue-context-errors.ts b/src/main/linear/issue-context-errors.ts index 64c020d2b52..4e76835eb6a 100644 --- a/src/main/linear/issue-context-errors.ts +++ b/src/main/linear/issue-context-errors.ts @@ -64,8 +64,7 @@ export function linearMessage(error: unknown): string { export function sanitizeLinearErrorMessage(message: string): string { // Why: provider text is useful in CLI errors, but raw SDK failures can embed secrets or user payloads. - return message - .split(/\r?\n\s+at\s+/)[0] + return stripLinearStackTrace(message) .replace( /(headers?\s*[:=]\s*)\{[^{}]*(?:authorization|token|api[-_]?key)[^{}]*\}/gi, '$1[REDACTED]' @@ -77,3 +76,35 @@ export function sanitizeLinearErrorMessage(message: string): string { .replace(/((?:body|comment|description)\s*[:=]\s*)(["']).*?\2/gi, '$1[REDACTED]') .trim() } + +function stripLinearStackTrace(message: string): string { + for (let index = 0; index < message.length; index += 1) { + const code = message.charCodeAt(index) + if (code !== 10 && code !== 13) { + continue + } + + let candidateStart = index + 1 + if (code === 13 && message.charCodeAt(candidateStart) === 10) { + candidateStart += 1 + } + while ( + candidateStart < message.length && + isLinearStackWhitespace(message.charCodeAt(candidateStart)) + ) { + candidateStart += 1 + } + if ( + message.startsWith('at', candidateStart) && + isLinearStackWhitespace(message.charCodeAt(candidateStart + 2)) + ) { + return message.slice(0, index) + } + } + + return message +} + +function isLinearStackWhitespace(code: number): boolean { + return code === 32 || (code >= 9 && code <= 13) +} diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index eb1c2a07c26..0b459cb5ed1 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -81,6 +81,25 @@ describe('parsePsOutput', () => { expect(rows[0].cpu).toBe(0) expect(rows[0].memory).toBe(0) }) + + it('parses process rows without line-array or whitespace-regex splitting', async () => { + const { parsePsOutput } = await loadCollector() + const splitSpy = vi.spyOn(String.prototype, 'split') + + const rows = parsePsOutput('10 1 0.5 256\r\n11 10 0 128') + + const usedUnboundedSplit = splitSpy.mock.calls.some( + ([separator]) => + (typeof separator === 'string' && separator === '\n') || + (separator instanceof RegExp && separator.source.includes('\\s+')) + ) + splitSpy.mockRestore() + expect(rows).toEqual([ + { pid: 10, ppid: 1, cpu: 0.5, memory: 256 * 1024 }, + { pid: 11, ppid: 10, cpu: 0, memory: 128 * 1024 } + ]) + expect(usedUnboundedSplit).toBe(false) + }) }) describe('parseWmicOutput', () => { diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index 5cc366534e4..a22fceac481 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -24,6 +24,10 @@ import { exec } from 'node:child_process' import { promisify } from 'node:util' import os from 'node:os' import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { + getProcessOutputFields, + iterateProcessOutputLines +} from '../../shared/process-output-field-scanner' import { app } from 'electron' import type { AppMemory, @@ -199,14 +203,8 @@ async function enumerateUnix(): Promise { /** Exported for tests: parses `ps -eo pid=,ppid=,pcpu=,rss=` output. */ export function parsePsOutput(stdout: string): ProcRow[] { const rows: ProcRow[] = [] - const lines = stdout.split('\n') - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim() - if (line.length === 0) { - continue - } - // Split on runs of whitespace. We requested exactly 4 columns. - const fields = line.split(/\s+/, 4) + for (const line of iterateProcessOutputLines(stdout)) { + const fields = getProcessOutputFields(line, 4) if (fields.length < 4) { continue } diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 41a9cbd955f..db7660ce09e 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -179,6 +179,19 @@ describe('registerAppMenu', () => { expect(paletteItem?.accelerator).toBeUndefined() }) + it('keeps Edit > Paste on the native Electron paste role in this split', () => { + const send = vi.fn() + getFocusedWindowMock.mockReturnValue({ webContents: { send } }) + registerAppMenu(buildMenuOptions()) + + const editSubmenu = getSubmenu(getTemplate(), 'Edit') + const pasteItem = editSubmenu.find((item) => item.role === 'paste') + + expect(pasteItem).toBeDefined() + expect(pasteItem?.click).toBeUndefined() + expect(send).not.toHaveBeenCalled() + }) + it.runIf(!isMac)('puts Settings and Exit under File on Windows/Linux', () => { registerAppMenu(buildMenuOptions()) diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index a694488ecb9..fee66359ca8 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -69,6 +69,20 @@ describe('local workspace port scanner parsing', () => { ]) }) + it('parses Windows netstat rows without whitespace regex splitting', () => { + const splitSpy = vi.spyOn(String.prototype, 'split') + const ports = parseNetstatListeningOutput( + 'TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 4242' + ) + const usedWhitespaceFieldSplit = splitSpy.mock.calls.some( + ([separator]) => separator instanceof RegExp && separator.source.includes('\\s+') + ) + splitSpy.mockRestore() + + expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, pid: 4242 }]) + expect(usedWhitespaceFieldSplit).toBe(false) + }) + it('parses Linux proc tcp listeners', () => { const ports = parseProcNetTcp( [ @@ -79,6 +93,23 @@ describe('local workspace port scanner parsing', () => { expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, inode: 12345 }]) }) + + it('parses Linux proc rows without whitespace regex splitting', () => { + const splitSpy = vi.spyOn(String.prototype, 'split') + const ports = parseProcNetTcp( + [ + ' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode', + ' 0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345' + ].join('\n') + ) + const usedWhitespaceFieldSplit = splitSpy.mock.calls.some( + ([separator]) => separator instanceof RegExp && separator.source.includes('\\s+') + ) + splitSpy.mockRestore() + + expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, inode: 12345 }]) + expect(usedWhitespaceFieldSplit).toBe(false) + }) }) describe('attributePortToWorkspace', () => { @@ -96,10 +127,8 @@ describe('attributePortToWorkspace', () => { }) it('falls back to command-line path evidence', () => { - const owner = attributePortToWorkspace( - { commandLine: 'node /repo/worktrees/feature/node_modules/vite/bin/vite.js' }, - worktrees - ) + const commandPath = path.posix.resolve('/repo/worktrees/feature/node_modules/vite/bin/vite.js') + const owner = attributePortToWorkspace({ commandLine: `node ${commandPath}` }, worktrees) expect(owner).toMatchObject({ worktreeId: 'repo::/repo/worktrees/feature', @@ -109,7 +138,7 @@ describe('attributePortToWorkspace', () => { it('requires command-line path boundary evidence', () => { const owner = attributePortToWorkspace( - { commandLine: 'node /repo/worktrees/feature-other/server.js' }, + { commandLine: `node ${path.posix.resolve('/repo/worktrees/feature-other/server.js')}` }, [worktrees[1]] ) diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index df6ad1f1e21..b6023e6bb01 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -9,6 +9,7 @@ import type { WorkspacePortProbe, WorkspacePortScanResult } from '../../shared/workspace-ports' +import { getProcessOutputFields } from '../../shared/process-output-field-scanner' import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher' import { WorkspacePortScanTimeoutBackoff } from './workspace-port-scan-timeout-backoff' @@ -157,11 +158,10 @@ export function parseLsofListeningOutput(output: string): RawListeningPort[] { export function parseNetstatListeningOutput(output: string): RawListeningPort[] { const ports: RawListeningPort[] = [] for (const line of output.split('\n')) { - const trimmed = line.trim() - if (!trimmed.toUpperCase().startsWith('TCP')) { + const fields = getProcessOutputFields(line, 6) + if (fields[0]?.toUpperCase() !== 'TCP') { continue } - const fields = trimmed.split(/\s+/) const stateIndex = fields.findIndex((field) => field.toUpperCase() === 'LISTENING') if (stateIndex < 2) { continue @@ -180,7 +180,7 @@ export function parseProcNetTcp(content: string): { host: string; port: number; const results: { host: string; port: number; inode: number }[] = [] const lines = content.split('\n') for (let i = 1; i < lines.length; i++) { - const fields = lines[i].trim().split(/\s+/) + const fields = getProcessOutputFields(lines[i], 10) if (fields.length < 10 || fields[3] !== '0A') { continue } diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 20399d5a6fd..367179338c2 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -66,6 +66,7 @@ describe('PTY provider dispatch', () => { isDestroyed: () => false, webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } } + const mainWindowIpcEvent = { sender: mainWindow.webContents } function setup(): void { handlers.clear() @@ -185,8 +186,8 @@ describe('PTY provider dispatch', () => { try { const write = handlers.get('pty:write') as (event: unknown, args: unknown) => void - write(null, { id: 'ssh:conn-a@@pty-1', data: 'a' }) - write(null, { id: 'ssh:conn-b@@pty-1', data: 'b' }) + write(mainWindowIpcEvent, { id: 'ssh:conn-a@@pty-1', data: 'a' }) + write(mainWindowIpcEvent, { id: 'ssh:conn-b@@pty-1', data: 'b' }) expect(providerA.write).toHaveBeenCalledWith('ssh:conn-a@@pty-1', 'a') expect(providerB.write).toHaveBeenCalledWith('ssh:conn-b@@pty-1', 'b') diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5f9e0258775..d3c2db15be1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -17,6 +17,7 @@ import type { WorkspaceSessionState } from '../../shared/types' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import { MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection' import { addWorktree, assertWorktreeCleanForRemoval, @@ -45,6 +46,12 @@ import { type RuntimeTerminalAgentStatusEvent } from './orca-runtime' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { + TERMINAL_INPUT_CHUNK_MAX_BYTES, + TERMINAL_INPUT_MAX_BYTES, + TERMINAL_INPUT_TOO_LARGE_ERROR +} from '../../shared/terminal-input' +import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider @@ -6213,6 +6220,49 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves Antigravity ready prompts with newline-heavy pasted tails without splitting', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + let pastedTail = '' + for (let index = 0; index < 90; index += 1) { + pastedTail += `${'pasted text '.repeat(25)}${index}\n` + } + const splitSpy = vi.spyOn(String.prototype, 'split') + + runtime.onPtyData( + 'pty-bg', + [ + 'Antigravity CLI 1.0.3\n', + 'user@example.com (Antigravity Business)\n', + pastedTail, + 'Gemini 4 Experimental (High)\n', + '~/orca/workspaces/orca/agy-dispatch-issue\n', + '>' + ].join(''), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running' + }) + const splitReadyTail = splitSpy.mock.contexts.some((context) => { + const value = typeof context === 'string' ? context : String(context) + return value.includes('antigravity cli') && value.includes('pasted text pasted text') + }) + expect(splitReadyTail).toBe(false) + }) + it('resolves tui-idle from an Antigravity prompt before the model line', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -6634,6 +6684,87 @@ describe('OrcaRuntimeService', () => { expect(writes).toEqual(['continue', '\r']) }) + it('chunks large terminal.send text before provider writes', async () => { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('') + + const result = await runtime.sendTerminal(handle, { text }) + + expect(result).toMatchObject({ + handle, + accepted: true, + bytesWritten: Buffer.byteLength(text, 'utf8') + }) + expect(writes).toEqual(['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail']) + }) + + it('yields while validating accepted large terminal.send text before provider writes', async () => { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + + vi.useFakeTimers() + try { + const sendPromise = runtime.sendTerminal(handle, { text }) + + expect(writes).toEqual([]) + + await vi.runAllTimersAsync() + const result = await sendPromise + + expect(result).toMatchObject({ + handle, + accepted: true, + bytesWritten: Buffer.byteLength(text, 'utf8') + }) + expect(writes.length).toBeGreaterThan(1) + expect(writes.join('')).toBe(text) + } finally { + vi.useRealTimers() + } + }) + + it('rejects oversized terminal.send text before provider writes', async () => { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + await expect( + runtime.sendTerminal(handle, { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }) + ).rejects.toThrow(TERMINAL_INPUT_TOO_LARGE_ERROR) + expect(writes).toEqual([]) + }) + it('reveals a background terminal session when focusing its handle', async () => { const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-adopted' }) const runtime = new OrcaRuntimeService(store) @@ -7367,7 +7498,7 @@ describe('OrcaRuntimeService', () => { ptysById: Map } ).ptysById.get('pty-1') - expect(pty?.lastOscTitle).toBe('x'.repeat(4092)) + expect(pty?.lastOscTitle).toBe('x'.repeat(MAX_OSC_TITLE_CHARS)) }) it('does not retain split ST-terminated string controls as preview text', async () => { @@ -7465,6 +7596,37 @@ describe('OrcaRuntimeService', () => { expect(retained).not.toContain('49m') }) + it('normalizes large CRLF-heavy terminal chunks without regex replacement or line splits', async () => { + const replaceSpy = vi.spyOn(String.prototype, 'replace') + const splitSpy = vi.spyOn(String.prototype, 'split') + const runtime = new OrcaRuntimeService(store) + + syncSinglePty(runtime) + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', `${'line\r\n'.repeat(10_000)}tail`, 100) + const read = await runtime.readTerminal(terminal.handle, { limit: 5 }) + const usedCrlfReplace = replaceSpy.mock.calls.some( + ([pattern], index) => + pattern instanceof RegExp && + pattern.source === '\\r\\n' && + typeof replaceSpy.mock.contexts[index] === 'string' && + replaceSpy.mock.contexts[index].length > 10_000 + ) + const usedLineSplit = splitSpy.mock.calls.some(([separator], index) => { + const splitSeparator = separator as unknown + return ( + (splitSeparator === '\n' || + (splitSeparator instanceof RegExp && splitSeparator.source === '\\r?\\n')) && + typeof splitSpy.mock.contexts[index] === 'string' && + splitSpy.mock.contexts[index].length > 10_000 + ) + }) + + expect(read.tail.at(-1)).toBe('tail') + expect(usedCrlfReplace).toBe(false) + expect(usedLineSplit).toBe(false) + }) + it('bounds retained partial terminal output before preview reads', async () => { const runtime = new OrcaRuntimeService(store) @@ -18487,7 +18649,8 @@ describe('OrcaRuntimeService', () => { expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) } finally { - await rm(parentDir, { recursive: true, force: true }) + // Why: Windows can keep a just-inspected git admin dir busy briefly. + await rm(parentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) } }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 56591eb938f..a0e8241b561 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -21,6 +21,11 @@ import { createAgentStatusOscProcessor, type ProcessedAgentStatusChunk } from '../../shared/agent-status-osc' +import { + isTerminalInputTooLargeWithYield, + TERMINAL_INPUT_TOO_LARGE_ERROR, + iterateTerminalInputChunks +} from '../../shared/terminal-input' import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { cleanupClaimedCloneTarget, @@ -7888,6 +7893,7 @@ export class OrcaRuntimeService { if (payload === null) { throw new Error('invalid_terminal_send') } + await assertTerminalInputWithinLimitWithYield(action.text) await this.writeTerminalAction(pty.pty.ptyId, action, payload) return { handle, @@ -7904,6 +7910,7 @@ export class OrcaRuntimeService { if (payload === null) { throw new Error('invalid_terminal_send') } + await assertTerminalInputWithinLimitWithYield(action.text) await this.writeTerminalAction(leaf.ptyId, action, payload) @@ -7919,24 +7926,27 @@ export class OrcaRuntimeService { action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string ): Promise { - // Why: TUI apps (Claude Code, etc.) treat a single large write as a paste - // event. Keep Enter/interrupt as a second write for both visible and - // background PTYs so CLI automation behaves the same either way. + // Why: direct terminal.send can carry paste-sized text from RPC/mobile + // clients; chunk text before PTY/ConPTY while preserving suffix separation. const hasText = typeof action.text === 'string' && action.text.length > 0 const hasSuffix = action.enter || action.interrupt - if (hasText && hasSuffix) { - const textWrote = this.ptyController?.write(ptyId, action.text!) ?? false - if (!textWrote) { - throw new Error('terminal_not_writable') - } + if (hasText) { + await this.writeTerminalInputChunks(ptyId, action.text!) + } + if (hasSuffix) { const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') - await new Promise((resolve) => setTimeout(resolve, 500)) + if (hasText) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false if (!suffixWrote) { throw new Error('terminal_not_writable') } return } + if (hasText) { + return + } const wrote = this.ptyController?.write(ptyId, payload) ?? false if (!wrote) { @@ -7944,6 +7954,21 @@ export class OrcaRuntimeService { } } + private async writeTerminalInputChunks(ptyId: string, text: string): Promise { + const chunks = iterateTerminalInputChunks(text) + let chunk = chunks.next() + while (!chunk.done) { + const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + chunk = chunks.next() + if (!chunk.done) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + } + async waitForTerminal( handle: string, options?: { @@ -20649,16 +20674,24 @@ export function appendNormalizedToTailBuffer( // Why: status UIs redraw a single line with CR/backspace/ANSI erase // controls. Terminal previews are text, not a full screen model, so retain // the latest visible redraw segment instead of appending every spinner frame. - const pieces = `${boundedPreviousPartialLine}${normalizedChunk}` - .split('\n') - .map(applyTerminalLineControls) - const nextPartialLine = (pieces.pop() ?? '').replace(/[ \t]+$/g, '') + const completedLines: string[] = [] + const combined = `${boundedPreviousPartialLine}${normalizedChunk}` + let lineStart = 0 + for (let index = 0; index < combined.length; index += 1) { + if (combined.charCodeAt(index) !== 0x0a) { + continue + } + completedLines.push( + trimTerminalLineRight(applyTerminalLineControls(combined.slice(lineStart, index))) + ) + lineStart = index + 1 + } + const nextPartialLine = trimTerminalLineRight( + applyTerminalLineControls(combined.slice(lineStart)) + ) const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) - const newCompleteLines = pieces.length - let nextLines = - newCompleteLines > 0 - ? [...previousLines, ...pieces.map((line) => line.replace(/[ \t]+$/g, ''))] - : previousLines + const newCompleteLines = completedLines.length + let nextLines = newCompleteLines > 0 ? [...previousLines, ...completedLines] : previousLines let truncated = previousPartialWasCapped || nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS if (nextLines.length > MAX_TAIL_LINES) { @@ -20691,6 +20724,18 @@ export function appendNormalizedToTailBuffer( } } +function trimTerminalLineRight(line: string): string { + let end = line.length + while (end > 0) { + const code = line.charCodeAt(end - 1) + if (code !== 0x20 && code !== 0x09) { + break + } + end -= 1 + } + return end === line.length ? line : line.slice(0, end) +} + function applyTerminalLineControls(line: string): string { const carriageIndex = line.lastIndexOf('\r') const latestRedraw = carriageIndex >= 0 ? line.slice(carriageIndex + 1) : line @@ -21019,6 +21064,15 @@ function buildSendPayload(action: { return payload.length > 0 ? payload : null } +async function assertTerminalInputWithinLimitWithYield(text: string | undefined): Promise { + if (!text) { + return + } + if (await isTerminalInputTooLargeWithYield(text)) { + throw new Error(TERMINAL_INPUT_TOO_LARGE_ERROR) + } +} + // Why: tui-idle relies on recognized agent CLIs setting OSC titles. If the // terminal runs an unsupported CLI (or a plain shell), no title transition // will ever fire. A 5-minute ceiling prevents indefinite hangs while still @@ -21111,29 +21165,47 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null { if (headerIndex === -1) { return null } - const readySegment = normalized.slice(headerIndex) - const lines = readySegment.split('\n') - let offset = 0 + let lineStart = headerIndex let modelIndex: number | null = null let promptIndex: number | null = null - for (const line of lines) { - const trimmed = line.trim() - const lineIndex = headerIndex + offset - if (lineIndex > headerIndex && trimmed.length > 0) { - if (modelIndex === null && trimmed.startsWith('gemini')) { - modelIndex = lineIndex + line.indexOf(trimmed) + // Why: ready previews can include echoed pasted output after the header; + // scan line bounds directly instead of splitting the whole terminal tail. + for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { + if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { + continue + } + let trimmedStart = lineStart + let trimmedEnd = cursor + while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { + trimmedStart += 1 + } + while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { + trimmedEnd -= 1 + } + if (lineStart > headerIndex && trimmedStart < trimmedEnd) { + if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { + modelIndex = trimmedStart } - if (promptIndex === null && trimmed === '>') { - promptIndex = lineIndex + line.indexOf('>') + if ( + promptIndex === null && + trimmedEnd - trimmedStart === 1 && + normalized.charCodeAt(trimmedStart) === 62 + ) { + promptIndex = trimmedStart } } - offset += line.length + 1 + lineStart = cursor + 1 } return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null } +function isTerminalWaitWhitespace(value: string, index: number): boolean { + const code = value.charCodeAt(index) + return code === 32 || (code >= 9 && code <= 13) +} + function findTerminalWaitBlockedSignal( normalized: string ): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { @@ -21426,36 +21498,56 @@ function normalizeTerminalChunk( return { text: chunk, pendingAnsi: '' } } const combined = `${pendingAnsi}${chunk}` - let text = '' + const parts: string[] = [] + let textStart = 0 for (let index = 0; index < combined.length; index += 1) { const char = combined[index] if (char === '\x1b') { + appendTerminalNormalizedSpan(parts, combined, textStart, index) if (index + 1 >= combined.length) { - return { text, pendingAnsi: combined.slice(index) } + return { text: parts.join(''), pendingAnsi: combined.slice(index) } } const parsed = parseAnsiControlSequence(combined, index) if (!parsed) { return { - text, + text: parts.join(''), pendingAnsi: trimPendingAnsiControl(combined.slice(index)) } } index = parsed.endIndex + textStart = index + 1 continue } if (char === '\r' && combined[index + 1] === '\n') { - text += '\n' + appendTerminalNormalizedSpan(parts, combined, textStart, index) + parts.push('\n') index += 1 + textStart = index + 1 continue } const code = combined.charCodeAt(index) if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0d) { - text += char - } else if (isTerminalPreviewPrintableCodeUnit(code)) { - text += char + appendTerminalNormalizedSpan(parts, combined, textStart, index) + parts.push(char) + textStart = index + 1 + } else if (!isTerminalPreviewPrintableCodeUnit(code)) { + appendTerminalNormalizedSpan(parts, combined, textStart, index) + textStart = index + 1 } } - return { text, pendingAnsi: '' } + appendTerminalNormalizedSpan(parts, combined, textStart, combined.length) + return { text: parts.join(''), pendingAnsi: '' } +} + +function appendTerminalNormalizedSpan( + parts: string[], + value: string, + start: number, + end: number +): void { + if (end > start) { + parts.push(value.slice(start, end)) + } } function isTerminalPreviewPrintableCodeUnit(code: number): boolean { diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index fa4a022d00a..7e18c5214f4 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -129,11 +129,16 @@ describe('remote runtime request connection integration', () => { }, cancelMobileDictationForConnection: () => {}, onClientDisconnected: () => {}, + showRepo: (selector: string) => { + if (selector !== repo.id && selector !== `id:${repo.id}`) { + throw new Error('repo_not_found') + } + return repo + }, onClientEvent: (listener: (event: RuntimeClientEvent) => void) => { clientEventListeners.add(listener) return () => clientEventListeners.delete(listener) }, - showRepo: () => repo, listDetectedManagedWorktrees: () => ({ repoId: repo.id, authoritative: true, @@ -348,7 +353,12 @@ describe('remote runtime request connection integration', () => { }, watchFileExplorer: async () => () => {}, listRepos: () => [repo], - showRepo: () => repo, + showRepo: (selector: string) => { + if (selector !== repo.id && selector !== `id:${repo.id}`) { + throw new Error('repo_not_found') + } + return repo + }, listDetectedManagedWorktrees: () => ({ repoId: repo.id, authoritative: true, diff --git a/src/main/runtime/rpc/dispatcher-computer-errors.test.ts b/src/main/runtime/rpc/dispatcher-computer-errors.test.ts index ddf549f6bc5..862973e3913 100644 --- a/src/main/runtime/rpc/dispatcher-computer-errors.test.ts +++ b/src/main/runtime/rpc/dispatcher-computer-errors.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { z } from 'zod' import { RpcDispatcher } from './dispatcher' -import { defineMethod, type RpcRequest } from './core' +import { defineMethod, InvalidArgumentError, type RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' function makeRequest(method: string, params?: unknown): RpcRequest { @@ -24,6 +24,19 @@ const METHODS = [ name: 'browser.click', params: z.object({ page: z.string().min(1, 'Missing page') }), handler: () => ({ ok: true }) + }), + defineMethod({ + name: 'orchestration.throwZod', + params: z.object({}), + handler: () => + z.object({ title: z.string().min(1, 'Handler title missing') }).parse({ title: '' }) + }), + defineMethod({ + name: 'orchestration.invalidArgument', + params: z.object({}), + handler: () => { + throw new InvalidArgumentError('Async validation rejected payload') + } }) ] @@ -81,4 +94,32 @@ describe('RpcDispatcher computer-use validation errors', () => { }) expect(response.ok === false ? response.error : null).not.toHaveProperty('data') }) + + it('preserves formatted Zod issue messages thrown by method handlers', async () => { + const dispatcher = new RpcDispatcher({ runtime: makeRuntime(), methods: METHODS }) + + const response = await dispatcher.dispatch(makeRequest('orchestration.throwZod', {})) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: 'Handler title missing' + } + }) + }) + + it('maps async validation errors to invalid_argument without shadowing Zod formatting', async () => { + const dispatcher = new RpcDispatcher({ runtime: makeRuntime(), methods: METHODS }) + + const response = await dispatcher.dispatch(makeRequest('orchestration.invalidArgument', {})) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: 'Async validation rejected payload' + } + }) + }) }) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index da6959d91b7..61b58e3aab0 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -4,6 +4,7 @@ // runtime-rpc.ts focused on framing/auth/connection bookkeeping. import { ZodError, + InvalidArgumentError, buildRegistry, formatZodError, isStreamingMethod, @@ -192,6 +193,13 @@ export class RpcDispatcher { } private mapError(request: RpcRequest, meta: RpcEnvelopeMeta, error: unknown): RpcResponse { + if (error instanceof ZodError) { + return this.invalidArgumentResponse(request, meta, formatZodError(error)) + } + if (error instanceof InvalidArgumentError) { + return this.invalidArgumentResponse(request, meta, error.message) + } + // Why: browser methods throw BrowserError with a structured `code`; // every other runtime error has a plain-message code. Routing by method // prefix keeps the mapping a single decision rather than a per-method @@ -202,9 +210,6 @@ export class RpcDispatcher { if (request.method.startsWith('emulator.')) { return mapEmulatorError(request.id, meta, error) } - if (error instanceof ZodError) { - return this.invalidArgumentResponse(request, meta, formatZodError(error)) - } return mapRuntimeError(request.id, meta, error) } diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 3736aaf5e74..fc1b1277fce 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -6,14 +6,12 @@ import { Element, Eval, Exec, - Fill, Find, FullScreenshot, Get, Goto, Highlight, Is, - KeyboardInsert, Keypress, LimitParam, ProfileCreate, @@ -31,10 +29,10 @@ import { TabProfileClone, TabShow, TabSwitch, - Type, Upload, Wait } from './browser-schemas' +import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' export const BROWSER_CORE_METHODS: RpcMethod[] = [ defineMethod({ @@ -52,16 +50,7 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: Goto, handler: async (params, { runtime }) => runtime.browserGoto(params) }), - defineMethod({ - name: 'browser.fill', - params: Fill, - handler: async (params, { runtime }) => runtime.browserFill(params) - }), - defineMethod({ - name: 'browser.type', - params: Type, - handler: async (params, { runtime }) => runtime.browserType(params) - }), + ...BROWSER_TEXT_METHODS, defineMethod({ name: 'browser.select', params: Select, @@ -247,11 +236,6 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: Is, handler: async (params, { runtime }) => runtime.browserIs(params) }), - defineMethod({ - name: 'browser.keyboardInsertText', - params: KeyboardInsert, - handler: async (params, { runtime }) => runtime.browserKeyboardInsertText(params) - }), defineMethod({ name: 'browser.find', params: Find, diff --git a/src/main/runtime/rpc/methods/browser-extras.ts b/src/main/runtime/rpc/methods/browser-extras.ts index dc47a1b0820..692c5e19b7f 100644 --- a/src/main/runtime/rpc/methods/browser-extras.ts +++ b/src/main/runtime/rpc/methods/browser-extras.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' +import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation' import { BrowserTarget, OptionalFiniteNumber } from '../schemas' import { ClipboardWrite, @@ -132,7 +133,10 @@ export const BROWSER_EXTRA_METHODS: RpcMethod[] = [ defineMethod({ name: 'browser.clipboardWrite', params: ClipboardWrite, - handler: async (params, { runtime }) => runtime.browserClipboardWrite(params) + handler: async (params, { runtime }) => { + await assertRpcClipboardTextWriteWithinLimit(params.text) + return runtime.browserClipboardWrite(params) + } }), defineMethod({ name: 'browser.dialogAccept', diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index db916054392..72a2893444e 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -9,6 +9,7 @@ import { OptionalFiniteNumber, OptionalPlainString, OptionalString, + requiredStringAllowingEmpty, requiredString } from '../schemas' @@ -22,9 +23,7 @@ export const Goto = BrowserTarget.extend({ export const Fill = BrowserTarget.extend({ element: requiredString('Missing required --element'), - value: z.custom((v) => typeof v === 'string', { - message: 'Missing required --value' - }) + value: requiredStringAllowingEmpty('Missing required --value') }) export const Type = BrowserTarget.extend({ diff --git a/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts b/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts new file mode 100644 index 00000000000..813dbe9d2e2 --- /dev/null +++ b/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts @@ -0,0 +1,30 @@ +import { defineMethod, type RpcMethod } from '../core' +import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation' +import { Fill, KeyboardInsert, Type } from './browser-schemas' + +export const BROWSER_TEXT_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'browser.fill', + params: Fill, + handler: async (params, { runtime }) => { + await assertRpcClipboardTextWriteWithinLimit(params.value) + return runtime.browserFill(params) + } + }), + defineMethod({ + name: 'browser.type', + params: Type, + handler: async (params, { runtime }) => { + await assertRpcClipboardTextWriteWithinLimit(params.input) + return runtime.browserType(params) + } + }), + defineMethod({ + name: 'browser.keyboardInsertText', + params: KeyboardInsert, + handler: async (params, { runtime }) => { + await assertRpcClipboardTextWriteWithinLimit(params.text) + return runtime.browserKeyboardInsertText(params) + } + }) +] diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts index 974953ee3b5..f17359d524a 100644 --- a/src/main/runtime/rpc/methods/browser.test.ts +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -2,9 +2,15 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' +import { + CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, + CLIPBOARD_TEXT_WRITE_MAX_BYTES, + CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR +} from '../../../../shared/clipboard-text' import { BROWSER_CORE_METHODS } from './browser-core' import { BROWSER_EXTRA_METHODS } from './browser-extras' import { BROWSER_SCREENCAST_METHODS } from './browser-screencast' +import { ClipboardWrite, Fill, KeyboardInsert, Type } from './browser-schemas' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } @@ -235,4 +241,115 @@ describe('browser RPC methods', () => { }) expect(runtime.browserCheck).not.toHaveBeenCalled() }) + + it('rejects oversized browser clipboard writes before runtime dispatch', async () => { + const secret = 'browser-secret-token' + const runtime = { + getRuntimeId: () => 'test-runtime', + browserClipboardWrite: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_EXTRA_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('browser.clipboardWrite', { + page: 'page-1', + text: secret + 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1) + }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR + } + }) + expect(JSON.stringify(response)).not.toContain(secret) + expect(runtime.browserClipboardWrite).not.toHaveBeenCalled() + }) + + it('leaves browser text byte limits to async handlers', () => { + const text = 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1) + + expect(Fill.safeParse({ element: '@e1', value: text }).success).toBe(true) + expect(Type.safeParse({ input: text }).success).toBe(true) + expect(KeyboardInsert.safeParse({ text }).success).toBe(true) + expect(ClipboardWrite.safeParse({ text }).success).toBe(true) + }) + + it('yields while validating large accepted browser text insertion before dispatch', async () => { + vi.useFakeTimers() + try { + const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + const runtime = { + getRuntimeId: () => 'test-runtime', + browserType: vi.fn().mockResolvedValue({ typed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS }) + + const responsePromise = dispatcher.dispatch(makeRequest('browser.type', { input: text })) + await Promise.resolve() + + expect(runtime.browserType).not.toHaveBeenCalled() + + await vi.runOnlyPendingTimersAsync() + const response = await responsePromise + + expect(response).toMatchObject({ + ok: true, + result: { typed: true } + }) + expect(runtime.browserType).toHaveBeenCalledWith({ input: text }) + } finally { + vi.useRealTimers() + } + }) + + it('rejects oversized browser text insertion before runtime dispatch', async () => { + const secret = 'browser-insert-secret' + const runtime = { + getRuntimeId: () => 'test-runtime', + browserFill: vi.fn().mockResolvedValue({ filled: '@e1' }), + browserType: vi.fn().mockResolvedValue({ typed: true }), + browserKeyboardInsertText: vi.fn().mockResolvedValue({ inserted: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS }) + const text = [secret, 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)].join('') + + const fillResponse = await dispatcher.dispatch( + makeRequest('browser.fill', { element: '@e1', value: text }) + ) + const typeResponse = await dispatcher.dispatch(makeRequest('browser.type', { input: text })) + const keyboardInsertResponse = await dispatcher.dispatch( + makeRequest('browser.keyboardInsertText', { text }) + ) + + expect(fillResponse).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR + } + }) + expect(typeResponse).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR + } + }) + expect(keyboardInsertResponse).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR + } + }) + expect(JSON.stringify([fillResponse, typeResponse, keyboardInsertResponse])).not.toContain( + secret + ) + expect(runtime.browserFill).not.toHaveBeenCalled() + expect(runtime.browserType).not.toHaveBeenCalled() + expect(runtime.browserKeyboardInsertText).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/clipboard.test.ts b/src/main/runtime/rpc/methods/clipboard.test.ts index d8b729d317c..118b21c766a 100644 --- a/src/main/runtime/rpc/methods/clipboard.test.ts +++ b/src/main/runtime/rpc/methods/clipboard.test.ts @@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' +import { + CLIPBOARD_IMAGE_MAX_BASE64_CHARS, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR +} from '../../../../shared/clipboard-image' const { saveClipboardImageBufferAsTempFile } = vi.hoisted(() => ({ saveClipboardImageBufferAsTempFile: vi.fn() @@ -73,6 +77,26 @@ describe('clipboard RPC methods', () => { expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() }) + it('rejects oversized direct clipboard image payloads before base64 validation', async () => { + const base64Test = vi.spyOn(RegExp.prototype, 'test') + const dispatcher = makeDispatcher() + + try { + const response = await dispatcher.dispatch( + makeRequest('clipboard.saveImageAsTempFile', { + contentBase64: 'A'.repeat(CLIPBOARD_IMAGE_MAX_BASE64_CHARS + 1) + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(JSON.stringify(response)).toContain(CLIPBOARD_IMAGE_TOO_LARGE_ERROR) + expect(base64Test).not.toHaveBeenCalled() + expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() + } finally { + base64Test.mockRestore() + } + }) + it('accepts chunked uploads and forwards the recorded connectionId on commit', async () => { saveClipboardImageBufferAsTempFile.mockResolvedValue('/tmp/orca-paste-image.png') const dispatcher = makeDispatcher() @@ -170,6 +194,35 @@ describe('clipboard RPC methods', () => { ).resolves.toMatchObject({ ok: false }) }) + it('rejects oversized clipboard image upload chunks before base64 validation', async () => { + const base64Test = vi.spyOn(RegExp.prototype, 'test') + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + try { + const response = await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4) + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(JSON.stringify(response)).toContain('Clipboard image chunk is too large') + expect(base64Test).not.toHaveBeenCalled() + expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() + } finally { + base64Test.mockRestore() + } + }) + it('rejects uploads beyond the existing total clipboard image limit', async () => { const dispatcher = makeDispatcher() diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index 4c5568f4e25..3d5212c7a52 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -2,8 +2,12 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file' import { randomUUID } from 'node:crypto' +import { + CLIPBOARD_IMAGE_MAX_BASE64_CHARS, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR +} from '../../../../shared/clipboard-image' -const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = 24 * 1024 * 1024 +const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8 const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000 @@ -71,14 +75,29 @@ function assertValidBase64Content(value: string): void { } } +function clipboardImageBase64Payload(maxChars: number, tooLargeMessage: string) { + return z.unknown().transform((value, ctx): string => { + if (typeof value !== 'string') { + ctx.addIssue({ code: 'custom', message: 'Missing image content' }) + return z.NEVER + } + if (value.length > maxChars) { + ctx.addIssue({ code: 'custom', message: tooLargeMessage }) + return z.NEVER + } + if (!isValidBase64(value)) { + ctx.addIssue({ code: 'custom', message: 'Clipboard image content must be base64' }) + return z.NEVER + } + return value + }) +} + const SaveImageAsTempFile = z.object({ - contentBase64: z - .unknown() - .refine((v): v is string => typeof v === 'string', { message: 'Missing image content' }) - .refine((value) => value.length <= MAX_CLIPBOARD_IMAGE_BASE64_CHARS, { - message: 'Clipboard image is too large' - }) - .refine(isValidBase64, 'Clipboard image content must be base64'), + contentBase64: clipboardImageBase64Payload( + MAX_CLIPBOARD_IMAGE_BASE64_CHARS, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR + ), connectionId: z.string().min(1).nullable().optional() }) @@ -87,21 +106,17 @@ const StartImageUpload = z.object({ .number() .int() .nonnegative() - .max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, 'Clipboard image is too large'), + .max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR), connectionId: z.string().min(1).nullable().optional() }) const AppendImageUploadChunk = z.object({ uploadId: z.string().min(1), offset: z.number().int().nonnegative(), - contentBase64: z - .unknown() - .refine((v): v is string => typeof v === 'string', { message: 'Missing image content' }) - .refine( - (value) => value.length <= CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, - 'Clipboard image chunk is too large' - ) - .refine(isValidBase64, 'Clipboard image content must be base64') + contentBase64: clipboardImageBase64Payload( + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, + 'Clipboard image chunk is too large' + ) }) const CommitImageUpload = z.object({ diff --git a/src/main/runtime/rpc/methods/computer-actions.test.ts b/src/main/runtime/rpc/methods/computer-actions.test.ts index 7edbc55e435..00649fbea78 100644 --- a/src/main/runtime/rpc/methods/computer-actions.test.ts +++ b/src/main/runtime/rpc/methods/computer-actions.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../../../shared/clipboard-text' const computerMocks = vi.hoisted(() => ({ callComputerSidecarAction: vi.fn(), @@ -193,6 +194,18 @@ describe('computer action RPC methods', () => { }) }) + it('leaves oversized computer paste text to async sidecar validation', async () => { + const secret = 'computer-paste-secret' + const text = secret + 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1) + + expect( + findMethod('computer.pasteText').params!.safeParse({ + app: 'Finder', + text + }).success + ).toBe(true) + }) + it('dispatches scroll and setValue actions through the sidecar', async () => { computerMocks.callComputerSidecarAction.mockResolvedValue({ ok: true }) diff --git a/src/main/runtime/rpc/methods/computer.test.ts b/src/main/runtime/rpc/methods/computer.test.ts index c8d2edbd836..6a01fac7d0a 100644 --- a/src/main/runtime/rpc/methods/computer.test.ts +++ b/src/main/runtime/rpc/methods/computer.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildRegistry } from '../core' +import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../../../shared/clipboard-text' const computerMocks = vi.hoisted(() => ({ callComputerSidecarAction: vi.fn(), @@ -237,6 +238,14 @@ describe('computer RPC methods', () => { findMethod('computer.hotkey').params!.parse({ app: 'Finder', key: 'Ctrl+A+B' }) ).toThrow(/Hotkey requires a modifier and one key/) }) + + it('leaves pasteText byte limits to async sidecar validation', () => { + const text = 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1) + + expect( + findMethod('computer.pasteText').params!.safeParse({ app: 'Finder', text }).success + ).toBe(true) + }) }) function findMethod(name: string) { diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index f040a6f3219..c01c52e3f6c 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,6 +1,11 @@ /* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */ import { z } from 'zod' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { + InvalidArgumentError, + defineMethod, + defineStreamingMethod, + type RpcAnyMethod +} from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import type { DriverState, OrcaRuntimeService } from '../../orca-runtime' import { @@ -14,6 +19,12 @@ import { } from '../../../../shared/terminal-stream-protocol' import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry' import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' +import { + TERMINAL_INPUT_MAX_BYTES, + TERMINAL_INPUT_TOO_LARGE_ERROR, + isTerminalInputTooLargeWithYield +} from '../../../../shared/terminal-input' +import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text' // Why: when a mobile client subscribes the server resizes the PTY to phone // dims and serializes the buffer. Sending only the visible screen meant @@ -31,7 +42,6 @@ const TERMINAL_OUTPUT_BATCH_MAX_BYTES = 64 * 1024 // Why: pending output is held for later binary frames, so cap the encoded // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 -const terminalStreamTextEncoder = new TextEncoder() let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -89,6 +99,7 @@ type TerminalMultiplexStream = { type TerminalOutputChunk = { data: string + bytes: number meta?: { seq?: number; rawLength?: number } } @@ -136,11 +147,15 @@ function createTerminalOutputBatcher( return } chunks.push(data) - bytes += terminalStreamByteLength(data) + const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + bytes += measurement.byteLength if (typeof meta?.seq === 'number') { lastSeq = meta.seq } - if (bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { + if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { flush() return } @@ -162,50 +177,73 @@ function createTerminalOutputBatcher( } } -function splitTerminalOutputFrameChunks( +function* iterateTerminalOutputFrameChunks( data: string, meta?: { seq?: number; rawLength?: number } -): TerminalOutputFrameChunk[] { - const bytes = encodeTerminalStreamText(data) - if (bytes.byteLength <= TERMINAL_STREAM_CHUNK_BYTES) { - return [{ bytes, seq: meta?.seq }] +): Generator { + if (!terminalStreamByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) { + yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } + return } - const chunks: TerminalOutputFrameChunk[] = [] const rawLength = meta?.rawLength ?? data.length const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined let chunk = '' let chunkBytes = 0 let chunkStartOffset = 0 let offset = 0 + let delayedChunk: { text: string; seq?: number } | null = null - const flushChunk = (): void => { + const takeChunk = (): { text: string; seq?: number } | null => { if (!chunk) { - return + return null } const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined - chunks.push({ bytes: encodeTerminalStreamText(chunk), seq: chunkSeq }) + const current = { text: chunk, seq: chunkSeq } chunk = '' chunkBytes = 0 chunkStartOffset = offset + return current } for (const part of data) { const partBytes = terminalStreamByteLength(part) if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { - flushChunk() + const nextChunk = takeChunk() + if (nextChunk) { + if (shouldDelayFinalSeq) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = nextChunk + } else { + yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } + } + } } chunk += part chunkBytes += partBytes offset += part.length } - flushChunk() - if (!canPreserveChunkSeq && typeof meta?.seq === 'number' && chunks.length > 0) { + const finalChunk = takeChunk() + if (shouldDelayFinalSeq) { // Why: if a future caller reports rawLength that cannot be mapped back to // UTF-16 offsets, only the final frame can safely carry the high-water mark. - chunks.at(-1)!.seq = meta.seq + if (finalChunk) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = finalChunk + } + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq } + } + return + } + if (finalChunk) { + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } } - return chunks } function isTerminalInputLockedForClient( @@ -225,6 +263,17 @@ function isTerminalInputLockedForClient( return runtime.getDriver(ptyId).kind === 'mobile' } +async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise { + if (!text) { + return + } + // Why: runtime/mobile sends can be paste-sized; validate outside Zod so + // accepted large input yields before terminal runtime dispatch. + if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) { + throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR) + } +} + function resolveMobileFloorClientId( driver: DriverState | null, client: TerminalViewportClient | undefined @@ -243,15 +292,22 @@ function appendPendingMultiplexOutput( data: string, meta?: { seq?: number; rawLength?: number } ): void { - stream.pendingOutput.push({ data, meta }) - stream.pendingOutputBytes += terminalStreamByteLength(data) + const remainingBudget = Math.max( + 1, + TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - stream.pendingOutputBytes + ) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + stream.pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + stream.pendingOutputBytes += measurement.byteLength const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputBytes) stream.pendingOutputBytes = trimmed.bytes stream.pendingOutputOverflowed ||= trimmed.overflowed } function trimPendingOutputToBudget( - pendingOutput: (string | TerminalOutputChunk)[], + pendingOutput: TerminalOutputChunk[], pendingOutputBytes: number ): { bytes: number; overflowed: boolean } { let omittedChunkCount = 0 @@ -260,7 +316,7 @@ function trimPendingOutputToBudget( omittedChunkCount < pendingOutput.length ) { const chunk = pendingOutput[omittedChunkCount] - pendingOutputBytes -= terminalStreamByteLength(typeof chunk === 'string' ? chunk : chunk.data) + pendingOutputBytes -= chunk.bytes omittedChunkCount += 1 } if (omittedChunkCount > 0) { @@ -269,8 +325,28 @@ function trimPendingOutputToBudget( return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 } } +function measureTerminalStreamByteLength( + data: string, + options: { stopAfterBytes?: number } = {} +): { byteLength: number; exceededLimit: boolean } { + return measureClipboardTextByteLength(data, options) +} + function terminalStreamByteLength(data: string): number { - return terminalStreamTextEncoder.encode(data).byteLength + return measureTerminalStreamByteLength(data).byteLength +} + +function terminalStreamByteLengthExceeds(data: string, maxBytes: number): boolean { + return measureTerminalStreamByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit +} + +function* iterateTerminalStreamTextPayloads(data: string): Generator> { + if (!data) { + return + } + for (const chunk of iterateTerminalOutputFrameChunks(data)) { + yield chunk.bytes + } } function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean { @@ -304,12 +380,15 @@ async function serializeBudgetedRequestedSnapshot( if (!serialized) { return null } - const bytes = terminalStreamByteLength(serialized.data) - if (bytes <= REQUESTED_SNAPSHOT_BYTE_BUDGET || rows === 0) { + const overByteBudget = terminalStreamByteLengthExceeds( + serialized.data, + REQUESTED_SNAPSHOT_BYTE_BUDGET + ) + if (!overByteBudget || rows === 0) { return { ...serialized, scrollbackRows: rows, - truncatedByByteBudget: rows < requestedRows || bytes > REQUESTED_SNAPSHOT_BYTE_BUDGET + truncatedByByteBudget: rows < requestedRows || overByteBudget } } } @@ -336,17 +415,15 @@ function sendSnapshotFrames( truncatedByByteBudget: options.truncatedByByteBudget === true }) ) - const bytes = encodeTerminalStreamText(options.data) let chunks = 0 - for (let offset = 0; offset < bytes.length; offset += TERMINAL_STREAM_CHUNK_BYTES) { + let bytes = 0 + for (const chunk of iterateTerminalStreamTextPayloads(options.data)) { chunks++ - sendFrame( - TerminalStreamOpcode.SnapshotChunk, - bytes.slice(offset, offset + TERMINAL_STREAM_CHUNK_BYTES) - ) + bytes += chunk.byteLength + sendFrame(TerminalStreamOpcode.SnapshotChunk, chunk) } sendFrame(TerminalStreamOpcode.SnapshotEnd) - return { bytes: bytes.byteLength, chunks } + return { bytes, chunks } } async function serializeBudgetedMobileSnapshot( @@ -364,13 +441,15 @@ async function serializeBudgetedMobileSnapshot( if (!serialized) { return null } - const bytes = terminalStreamByteLength(serialized.data) - if (bytes <= MOBILE_SNAPSHOT_BYTE_BUDGET || rows === 0) { + const overByteBudget = terminalStreamByteLengthExceeds( + serialized.data, + MOBILE_SNAPSHOT_BYTE_BUDGET + ) + if (!overByteBudget || rows === 0) { return { ...serialized, scrollbackRows: rows, - truncatedByByteBudget: - rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || bytes > MOBILE_SNAPSHOT_BYTE_BUDGET + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget } } } @@ -747,6 +826,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.send', params: TerminalSend, handler: async (params, { runtime }) => { + await assertTerminalSendTextWithinLimit(params.text) const leaf = runtime.resolveLeafForHandle(params.terminal) const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { @@ -1217,7 +1297,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ lastResizeCols: undefined, resizeGeneration: 0, outputBatcher: createTerminalOutputBatcher((data, meta) => { - for (const chunk of splitTerminalOutputFrameChunks(data, meta)) { + for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { sendFrame(request.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) } }), @@ -1536,7 +1616,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // resize re-stream so it only fires on an actual width change. let lastResizeCols: number | undefined let resizeGeneration = 0 - const pendingOutput: string[] = [] + const pendingOutput: TerminalOutputChunk[] = [] let pendingOutputBytes = 0 let unsubscribeData = (): void => {} let unsubscribeResize = (): void => {} @@ -1583,7 +1663,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload })) } outputBatcher = createTerminalOutputBatcher((data) => { - for (const chunk of splitTerminalOutputFrameChunks(data)) { + for (const chunk of iterateTerminalOutputFrameChunks(data)) { sendFrame(TerminalStreamOpcode.Output, chunk.bytes) } }) @@ -1644,9 +1724,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } if (buffering) { - pendingOutput.push(data) - pendingOutputBytes += terminalStreamByteLength(data) - pendingOutputBytes = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes).bytes + const remainingBudget = Math.max( + 1, + TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes + ) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + pendingOutput.push({ data, bytes: measurement.byteLength }) + pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) + pendingOutputBytes = trimmed.bytes return } outputBatcher?.push(data) @@ -1699,7 +1787,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ lastResizeCols = serialized?.cols ?? size?.cols buffering = false for (const item of pendingOutput.splice(0)) { - outputBatcher.push(item) + outputBatcher.push(item.data, item.meta) } pendingOutputBytes = 0 outputBatcher.flush() diff --git a/src/main/runtime/rpc/rpc-clipboard-text-validation.ts b/src/main/runtime/rpc/rpc-clipboard-text-validation.ts new file mode 100644 index 00000000000..cdfa135de02 --- /dev/null +++ b/src/main/runtime/rpc/rpc-clipboard-text-validation.ts @@ -0,0 +1,19 @@ +import { + CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR, + assertClipboardTextWriteWithinLimitWithYield, + isClipboardTextWriteTooLargeError +} from '../../../shared/clipboard-text' +import { InvalidArgumentError } from './core' + +export async function assertRpcClipboardTextWriteWithinLimit(text: string): Promise { + try { + // Why: large accepted text must yield outside Zod's synchronous parse path + // while preserving the RPC invalid_argument contract for rejected payloads. + await assertClipboardTextWriteWithinLimitWithYield(text) + } catch (error) { + if (isClipboardTextWriteTooLargeError(error)) { + throw new InvalidArgumentError(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR) + } + throw error + } +} diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 2966a825e23..830268b7861 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -441,6 +441,7 @@ describe('terminal multiplex RPC', () => { binaryFrames.splice(0) const multibyteOutput = '界'.repeat(22_000) + const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode') dataListenerRef.current?.(multibyteOutput, { seq: multibyteOutput.length, rawLength: multibyteOutput.length @@ -457,6 +458,8 @@ describe('terminal multiplex RPC', () => { expect( outputFrames.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')).join('') ).toBe(multibyteOutput) + expect(encodeSpy).not.toHaveBeenCalledWith(multibyteOutput) + encodeSpy.mockRestore() cleanups.get('terminal-multiplex:conn-multibyte-output-batch')?.() await dispatchPromise diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index 11e2b7476c8..2df3dbc7173 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -171,6 +171,93 @@ describe('terminal output batching', () => { } }) + it('encodes large binary terminal output lazily before the first output frame', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + let captureOutputFrames = false + let firstOutputEncodeCount: number | undefined + const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode') + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue(false) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-1', + sendBinary: (bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + if ( + captureOutputFrames && + firstOutputEncodeCount === undefined && + frame?.opcode === TerminalStreamOpcode.Output + ) { + firstOutputEncodeCount = encodeSpy.mock.calls.length + } + binaryFrames.push(bytes) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + + binaryFrames.length = 0 + encodeSpy.mockClear() + captureOutputFrames = true + const output = 'x'.repeat(48 * 1024 * 3 + 17) + dataListenerRef.current?.(output) + + const outputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + expect(outputFrames.length).toBeGreaterThan(1) + expect(firstOutputEncodeCount).toBe(1) + expect( + outputFrames.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')).join('') + ).toBe(output) + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) + it('routes binary terminal input frames back to the subscribed PTY', async () => { const handlers = new Map< number, diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 1950a211682..d7047480a31 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' import { TERMINAL_METHODS } from './methods/terminal' +import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text' +import { + TERMINAL_INPUT_MAX_BYTES, + TERMINAL_INPUT_TOO_LARGE_ERROR +} from '../../../shared/terminal-input' function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { @@ -16,6 +21,10 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('terminal send RPC', () => { + afterEach(() => { + vi.useRealTimers() + }) + it('reports whether a terminal handle is running a recognized agent', async () => { const runtime = stubRuntime({ isTerminalRunningAgent: vi.fn().mockResolvedValue(true) @@ -101,6 +110,92 @@ describe('terminal send RPC', () => { expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') }) + it('rejects oversized terminal send text before runtime dispatch', async () => { + const secret = 'terminal-send-secret' + const runtime = stubRuntime({ + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: [secret, 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1)].join('') + }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: TERMINAL_INPUT_TOO_LARGE_ERROR + } + }) + expect(JSON.stringify(response)).not.toContain(secret) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + + it('rejects multibyte oversized terminal send text before runtime dispatch', async () => { + const runtime = stubRuntime({ + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const text = '😀'.repeat(Math.floor(TERMINAL_INPUT_MAX_BYTES / 4) + 1) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text + }) + ) + + expect(text.length).toBeLessThan(TERMINAL_INPUT_MAX_BYTES) + expect(response).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: TERMINAL_INPUT_TOO_LARGE_ERROR + } + }) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + + it('yields while validating large accepted terminal send text before runtime dispatch', async () => { + vi.useFakeTimers() + const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: text.length + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const responsePromise = dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text + }) + ) + await Promise.resolve() + + expect(runtime.sendTerminal).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(0) + await expect(responsePromise).resolves.toMatchObject({ + ok: true, + result: { send: { accepted: true } } + }) + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text, + enter: false, + interrupt: false + }) + }) + it('routes terminal restore fit through the runtime driver state machine', async () => { const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b90e8fc748a..1f7c3d52d2b 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -109,6 +109,7 @@ export class SshRelaySession { private _onReady: ((targetId: string) => void) | null = null private portScanner: PortScanner | null = null private currentConnection: SshConnection | null = null + private hostPlatform: RemoteHostPlatform | null = null private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null constructor( @@ -174,6 +175,10 @@ export class SshRelaySession { return this.mux } + getHostPlatform(): RemoteHostPlatform | null { + return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform + } + getPortScanner(): PortScanner | null { return this.portScanner } @@ -199,6 +204,7 @@ export class SshRelaySession { try { const { transport, remoteHome, remoteRelayDir, nodePath, sockPath, hostPlatform } = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) + this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { @@ -325,6 +331,7 @@ export class SshRelaySession { try { const { transport, remoteHome, remoteRelayDir, nodePath, sockPath, hostPlatform } = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) + this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { diff --git a/src/main/ssh/ssh-remote-platform-detection.test.ts b/src/main/ssh/ssh-remote-platform-detection.test.ts new file mode 100644 index 00000000000..a8ffd416830 --- /dev/null +++ b/src/main/ssh/ssh-remote-platform-detection.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' + +const execCommandMock = vi.hoisted(() => vi.fn()) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: execCommandMock +})) + +const { detectRemoteHostPlatform } = await import('./ssh-remote-platform-detection') + +const conn = {} as SshConnection + +describe('detectRemoteHostPlatform', () => { + beforeEach(() => { + execCommandMock.mockReset() + }) + + it('detects POSIX hosts from uname output', async () => { + execCommandMock.mockResolvedValueOnce('Linux x86_64\n') + + await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ + relayPlatform: 'linux-x64', + os: 'linux', + arch: 'x64', + pathFlavor: 'posix' + }) + expect(execCommandMock).toHaveBeenCalledWith(conn, 'uname -sm') + }) + + it('falls back to PowerShell detection for Windows remotes', async () => { + execCommandMock + .mockRejectedValueOnce(new Error('uname unavailable')) + .mockResolvedValueOnce('Windows AMD64\r\n') + + await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ + relayPlatform: 'win32-x64', + os: 'win32', + arch: 'x64', + pathFlavor: 'windows' + }) + expect(execCommandMock).toHaveBeenNthCalledWith( + 2, + conn, + expect.stringContaining('powershell.exe'), + { wrapCommand: false } + ) + }) + + it('returns null when neither probe yields a supported platform', async () => { + execCommandMock.mockResolvedValueOnce('Linux').mockResolvedValueOnce('FreeBSD x86_64') + + await expect(detectRemoteHostPlatform(conn)).resolves.toBeNull() + }) + + it('does not use whitespace regex splitting for remote platform output', async () => { + const splitSpy = vi.spyOn(String.prototype, 'split') + execCommandMock.mockResolvedValueOnce('Darwin arm64 extra') + + await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ + relayPlatform: 'darwin-arm64' + }) + + const usedWhitespaceFieldSplit = splitSpy.mock.calls.some( + ([separator]) => separator instanceof RegExp && separator.source.includes('\\s+') + ) + splitSpy.mockRestore() + expect(usedWhitespaceFieldSplit).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-remote-platform-detection.ts b/src/main/ssh/ssh-remote-platform-detection.ts index 6742100564f..f31f53f4ec4 100644 --- a/src/main/ssh/ssh-remote-platform-detection.ts +++ b/src/main/ssh/ssh-remote-platform-detection.ts @@ -1,4 +1,5 @@ import type { SshConnection } from './ssh-connection' +import { getProcessOutputFields } from '../../shared/process-output-field-scanner' import { parseUnameToRelayPlatform, type RelayPlatform } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' @@ -18,7 +19,7 @@ export async function detectRemoteHostPlatform( async function detectUnamePlatform(conn: SshConnection): Promise { try { const output = await execCommand(conn, 'uname -sm') - const parts = output.trim().split(/\s+/) + const parts = getProcessOutputFields(output, 2) if (parts.length < 2) { return null } @@ -37,7 +38,7 @@ async function detectWindowsPlatform(conn: SshConnection): Promise { + vi.restoreAllMocks() +}) + function oscTitle(title: string): string { return `\x1b]0;${title}\x07` } @@ -151,6 +155,47 @@ describe('AgentDetector', () => { expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120) }) + it('detects meaningful output in large CRLF chunks without regex replacement passes', () => { + const replaceSpy = vi.spyOn(String.prototype, 'replace') + const stats = { + onAgentStart: vi.fn(), + onAgentStop: vi.fn() + } + const detector = new AgentDetector(stats as never) + + detector.onData('pty-1', oscTitle('Codex working'), 100) + detector.onData('pty-1', `${'\r\n'.repeat(20_000)}done`, 120) + detector.onData('pty-1', oscTitle('Codex done'), 140) + + expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120) + const usedFullReplacement = replaceSpy.mock.calls.some( + ([pattern]) => + pattern instanceof RegExp && + (pattern.source === '\\r\\n' || pattern.source.includes('\\x1b')) + ) + expect(usedFullReplacement).toBe(false) + }) + + it('ignores large incomplete OSC payloads without regex replacement passes', () => { + const replaceSpy = vi.spyOn(String.prototype, 'replace') + const stats = { + onAgentStart: vi.fn(), + onAgentStop: vi.fn() + } + const detector = new AgentDetector(stats as never) + + detector.onData('pty-1', oscTitle('Codex working'), 100) + detector.onData('pty-1', 'real output', 120) + detector.onData('pty-1', `\x1b]0;${'pasted noise'.repeat(20_000)}`, 140) + detector.onData('pty-1', oscTitle('Codex done'), 160) + + expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120) + const usedAnsiReplacement = replaceSpy.mock.calls.some( + ([pattern]) => pattern instanceof RegExp && pattern.source.includes('\\x1b') + ) + expect(usedAnsiReplacement).toBe(false) + }) + it('keeps capped split OSC title tails from becoming meaningful output', () => { const stats = { onAgentStart: vi.fn(), diff --git a/src/main/stats/agent-detector.ts b/src/main/stats/agent-detector.ts index 0f2cd418276..81330ce280d 100644 --- a/src/main/stats/agent-detector.ts +++ b/src/main/stats/agent-detector.ts @@ -29,45 +29,30 @@ const MEANINGFUL_CONTENT_SCAN_TAIL_LIMIT = 4096 * orca-runtime.ts normalizeTerminalChunk but avoids importing the runtime. */ function hasMeaningfulContent(chunk: string): boolean { - // Why: large plain-text PTY bursts should not pay the full ANSI stripping - // chain just to prove they contain visible output. for (let index = 0; index < chunk.length; index++) { const code = chunk.charCodeAt(index) + if (code === 0x1b) { + const controlEnd = parseMeaningfulControlSequence(chunk, index) + if (controlEnd === null) { + return false + } + index = controlEnd + continue + } if ( - code === 0x1b || code === 0x7f || code < 0x09 || (code > 0x0d && code < 0x20) || (code >= 0x80 && code <= 0x9f) ) { - break + continue } if (code > 0x20) { return true } } - const stripped = chunk - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - // eslint-disable-next-line no-control-regex - .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences - // eslint-disable-next-line no-control-regex - .replace(/\x1b\][^\x07]*(?:\x1b)?$/g, '') // incomplete OSC tail - // eslint-disable-next-line no-control-regex - .replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, '') // ST-terminated string controls - // eslint-disable-next-line no-control-regex - .replace(/\x1b[PX^_][\s\S]*(?:\x1b)?$/g, '') // incomplete string-control tail - // eslint-disable-next-line no-control-regex - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences - // eslint-disable-next-line no-control-regex - .replace(/\x1b[@-_]/g, '') // Fe sequences - // eslint-disable-next-line no-control-regex - .replace(/\u0008/g, '') // backspace - // eslint-disable-next-line no-control-regex - .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '') // non-printable - .trim() - return stripped.length > 0 + return false } /** diff --git a/src/main/text-generation/pull-request-context.test.ts b/src/main/text-generation/pull-request-context.test.ts index aeb3247dc31..d7c009d0db5 100644 --- a/src/main/text-generation/pull-request-context.test.ts +++ b/src/main/text-generation/pull-request-context.test.ts @@ -2,11 +2,15 @@ // Why: PR context generation depends on command order across remote-state // variants; keeping the table of git command mocks together makes regressions // easier to audit than splitting the suite by helper. -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { getPullRequestDraftContext } from './pull-request-context' type GitExec = Parameters[0] +afterEach(() => { + vi.restoreAllMocks() +}) + function createContextInput(base = 'main') { return { base, @@ -321,6 +325,35 @@ describe('getPullRequestDraftContext', () => { ) }) + it('handles newline-heavy remote state and fetch errors without line-array splitting', async () => { + const splitSpy = vi.spyOn(String.prototype, 'split') + const execGit = vi.fn(async (args) => { + if (args[0] === 'remote') { + return { stdout: `${'\r\n'.repeat(10_000)}origin\r\n`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: `${'\r\n'.repeat(10_000)}origin/main\r\n`, stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error( + `Command failed: git fetch\r\n${'remote: progress\r\n'.repeat(10_000)}fatal: unable to access origin\r\n` + ) + } + throw new Error(`Unexpected git args: ${args.join(' ')}`) + }) + + await expect(getPullRequestDraftContext(execGit, createContextInput())).rejects.toThrow( + 'Fetch before generating PR details failed: fatal: unable to access origin' + ) + + const usedLineSplit = splitSpy.mock.calls.some( + ([separator]) => + (typeof separator === 'string' && separator === '\n') || + (separator instanceof RegExp && separator.source === '\\r?\\n') + ) + expect(usedLineSplit).toBe(false) + }) + it('returns null without running git when the base is invalid', async () => { const execGit = vi.fn() diff --git a/src/main/text-generation/pull-request-context.ts b/src/main/text-generation/pull-request-context.ts index 1eaf078f63d..24e13265688 100644 --- a/src/main/text-generation/pull-request-context.ts +++ b/src/main/text-generation/pull-request-context.ts @@ -27,11 +27,13 @@ function summarizeGitError(error: unknown): string { if (!(error instanceof Error)) { return 'Git command failed.' } - const lines = error.message - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - return lines.at(-1) ?? error.message + for (const rawLine of iterateGitOutputLinesFromEnd(error.message)) { + const line = rawLine.trim() + if (line.length > 0) { + return line + } + } + return error.message } async function requiredExec(execGit: GitExec, args: string[], label: string): Promise { @@ -55,10 +57,56 @@ type RemoteBranch = { } function splitGitLines(output: string): string[] { - return output - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) + const lines: string[] = [] + for (const rawLine of iterateGitOutputLines(output)) { + const line = rawLine.trim() + if (line.length > 0) { + lines.push(line) + } + } + return lines +} + +function* iterateGitOutputLines(output: string): Generator { + let lineStart = 0 + + for (let index = 0; index < output.length; index++) { + const code = output.charCodeAt(index) + if (code !== 10 && code !== 13) { + continue + } + + yield output.slice(lineStart, index) + if (code === 13 && output.charCodeAt(index + 1) === 10) { + index++ + } + lineStart = index + 1 + } + + if (lineStart <= output.length) { + yield output.slice(lineStart) + } +} + +function* iterateGitOutputLinesFromEnd(output: string): Generator { + let lineEnd = output.length + let index = output.length - 1 + + while (index >= 0) { + const code = output.charCodeAt(index) + if (code !== 10 && code !== 13) { + index-- + continue + } + + const delimiterStart = + code === 10 && index > 0 && output.charCodeAt(index - 1) === 13 ? index - 1 : index + yield output.slice(index + 1, lineEnd) + lineEnd = delimiterStart + index = delimiterStart - 1 + } + + yield output.slice(0, lineEnd) } async function getRemoteState(execGit: GitExec): Promise { diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index e6d04fe6bf2..1091ef71417 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -444,6 +444,61 @@ describe('attachMainWindowServices', () => { expect(removeListenerMock).toHaveBeenCalledWith(channel, relayHandler) }) + it('relays native file drops only from the owning renderer webContents', () => { + const sendMock = vi.fn() + const mainWindow = createMainWindow({ send: sendMock }) + + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + + const channel = 'terminal:file-dropped-from-preload' + const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1] + const payload = { paths: ['/tmp/a'], target: 'editor' } + + relayHandler?.({ sender: { id: 999 } }, payload) + + expect(sendMock).not.toHaveBeenCalled() + + relayHandler?.({ sender: mainWindow.webContents }, payload) + + expect(sendMock).toHaveBeenCalledWith('terminal:file-drop', payload) + }) + + it('ignores malformed native file-drop payloads from the owning renderer', () => { + const sendMock = vi.fn() + const mainWindow = createMainWindow({ send: sendMock }) + + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + + const channel = 'terminal:file-dropped-from-preload' + const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1] + + relayHandler?.( + { sender: mainWindow.webContents }, + { paths: ['C:\\Users\\alice\\secret.txt'], target: 'browser' } + ) + relayHandler?.( + { sender: mainWindow.webContents }, + { paths: ['/tmp/a'], target: 'file-explorer' } + ) + + expect(sendMock).not.toHaveBeenCalled() + }) + + it('ignores native file drops after the owning webContents is destroyed', () => { + const sendMock = vi.fn() + const mainWindow = createMainWindow({ send: sendMock }) + + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + + const channel = 'terminal:file-dropped-from-preload' + const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1] + mainWindow.webContents.isDestroyed?.mockReturnValue(true) + + relayHandler?.({ sender: mainWindow.webContents }, { paths: ['/tmp/a'], target: 'editor' }) + + expect(sendMock).not.toHaveBeenCalled() + }) + it('clears the runtime notifier when the owning window closes', () => { const mainWindowOnMock = vi.fn() const mainWindow = createMainWindow() diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 8548e9f8fce..583437ff3b1 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -32,7 +32,7 @@ import type { RuntimeMarkdownSaveTabResult } from '../../shared/mobile-markdown-document' import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' -import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' @@ -353,9 +353,17 @@ function registerRuntimeWindowLifecycle( function registerFileDropRelay(mainWindow: BrowserWindow): void { const channel = 'terminal:file-dropped-from-preload' + const mainWebContents = mainWindow.webContents ipcMain.removeAllListeners(channel) - const relayFileDrop = (_event: Electron.IpcMainEvent, args: NativeFileDropPayload): void => { - if (mainWindow.isDestroyed()) { + const relayFileDrop = (event: Electron.IpcMainEvent, args: NativeFileDropPayload): void => { + if ( + mainWindow.isDestroyed() || + mainWebContents.isDestroyed() || + event.sender !== mainWebContents + ) { + return + } + if (!isNativeFileDropPayload(args)) { return } diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index b52710014c8..6fbdf92ef4c 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto' import { app } from 'electron' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null @@ -23,6 +24,8 @@ export async function saveClipboardImageBufferAsTempFile( buffer: Buffer, args?: SaveClipboardImageAsTempFileArgs ): Promise { + assertClipboardImageByteLengthWithinLimit(buffer.byteLength) + const fileName = `orca-paste-${Date.now()}-${randomUUID()}.png` if (args?.connectionId) { diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index dc73d2332a2..6f8d308c75f 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' +import { + CLIPBOARD_IMAGE_MAX_BASE64_CHARS, + CLIPBOARD_IMAGE_MAX_PIXELS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES +} from '../../shared/clipboard-image' const { removeHandlerMock, @@ -67,7 +72,10 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ } })) -import { registerClipboardHandlers } from './clipboard-ipc-handlers' +import { + registerClipboardHandlers, + setTrustedClipboardRendererWebContentsId +} from './clipboard-ipc-handlers' function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() @@ -80,6 +88,33 @@ function getRegisteredHandlers(): Map unknown> { return handlers } +function makeClipboardEvent(senderOverrides: Record = {}): { + sender: Record +} { + return { + sender: { + id: 17, + getType: () => 'window', + getURL: () => 'file:///orca/index.html', + isDestroyed: () => false, + ...senderOverrides + } + } +} + +function trackPromiseSettled(promise: Promise): () => boolean { + let settled = false + void promise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + return () => settled +} + describe('registerClipboardHandlers', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(1760000000000) @@ -94,13 +129,16 @@ describe('registerClipboardHandlers', () => { randomUUIDMock.mockReset() randomUUIDMock.mockReturnValue('00000000-0000-4000-8000-000000000000') getSshFilesystemProviderMock.mockReset() + setTrustedClipboardRendererWebContentsId(null) }) afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() vi.restoreAllMocks() }) - it('registers normal and selection text clipboard IPC handlers', () => { + it('registers normal and selection text clipboard IPC handlers', async () => { clipboardReadTextMock.mockImplementation((clipboardType?: string) => clipboardType === 'selection' ? 'selection text' : 'standard text' ) @@ -108,10 +146,14 @@ describe('registerClipboardHandlers', () => { registerClipboardHandlers() const handlers = getRegisteredHandlers() - expect(handlers.get('clipboard:readText')?.()).toBe('standard text') - expect(handlers.get('clipboard:readSelectionText')?.()).toBe('selection text') - handlers.get('clipboard:writeText')?.({}, 'normal text') - handlers.get('clipboard:writeSelectionText')?.({}, 'primary text') + await expect(handlers.get('clipboard:readText')?.(makeClipboardEvent())).resolves.toBe( + 'standard text' + ) + await expect(handlers.get('clipboard:readSelectionText')?.(makeClipboardEvent())).resolves.toBe( + 'selection text' + ) + await handlers.get('clipboard:writeText')?.(makeClipboardEvent(), 'normal text') + await handlers.get('clipboard:writeSelectionText')?.(makeClipboardEvent(), 'primary text') expect(clipboardReadTextMock).toHaveBeenCalledWith() expect(clipboardReadTextMock).toHaveBeenCalledWith('selection') @@ -119,6 +161,140 @@ describe('registerClipboardHandlers', () => { expect(clipboardWriteTextMock).toHaveBeenCalledWith('primary text', 'selection') }) + it('rejects clipboard IPC from senders outside the current main renderer', async () => { + setTrustedClipboardRendererWebContentsId(17) + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + const untrustedEvent = makeClipboardEvent({ id: 42 }) + await expect(handlers.get('clipboard:readText')?.(untrustedEvent)).rejects.toThrow( + 'Unauthorized clipboard IPC sender' + ) + await expect( + handlers.get('clipboard:writeText')?.(untrustedEvent, 'copied-secret-token-value') + ).rejects.toThrow('Unauthorized clipboard IPC sender') + await expect( + handlers.get('clipboard:saveImageAsTempFile')?.(untrustedEvent, { + connectionId: 'ssh-secret' + }) + ).rejects.toThrow('Unauthorized clipboard IPC sender') + expect(() => + handlers.get('clipboard:writeImage')?.(untrustedEvent, 'data:image/png;base64,AAAA') + ).toThrow('Unauthorized clipboard IPC sender') + + expect(clipboardReadTextMock).not.toHaveBeenCalled() + expect(clipboardWriteTextMock).not.toHaveBeenCalled() + expect(clipboardReadImageMock).not.toHaveBeenCalled() + expect(nativeImageCreateFromBufferMock).not.toHaveBeenCalled() + expect(clipboardWriteImageMock).not.toHaveBeenCalled() + expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + }) + + it('rejects clipboard IPC from destroyed, browser, and mismatched dev-origin senders', async () => { + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:readText')?.(makeClipboardEvent({ isDestroyed: () => true })) + ).rejects.toThrow('Unauthorized clipboard IPC sender') + await expect( + handlers.get('clipboard:readText')?.(makeClipboardEvent({ getType: () => 'webview' })) + ).rejects.toThrow('Unauthorized clipboard IPC sender') + + vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173') + + await expect( + handlers.get('clipboard:readText')?.( + makeClipboardEvent({ getURL: () => 'http://127.0.0.1:5173/workspace' }) + ) + ).rejects.toThrow('Unauthorized clipboard IPC sender') + await expect( + handlers.get('clipboard:readText')?.(makeClipboardEvent({ getURL: () => 'not a url' })) + ).rejects.toThrow('Unauthorized clipboard IPC sender') + + expect(clipboardReadTextMock).not.toHaveBeenCalled() + }) + + it('rejects oversized text clipboard IPC reads without returning clipboard contents', async () => { + clipboardReadTextMock.mockImplementation((clipboardType?: string) => + clipboardType === 'selection' ? 'selection secret' : 'standard secret' + ) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:readText')?.(makeClipboardEvent(), { maxBytes: 4 }) + ).rejects.toThrow('Clipboard text is too large for this paste target.') + await expect( + handlers.get('clipboard:readSelectionText')?.(makeClipboardEvent(), { maxBytes: 4 }) + ).rejects.toThrow('Clipboard text is too large for this paste target.') + }) + + it('yields while measuring large accepted text clipboard IPC reads', async () => { + vi.useFakeTimers() + const text = 'é'.repeat(300_000) + clipboardReadTextMock.mockReturnValue(text) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + const result = handlers.get('clipboard:readText')?.(makeClipboardEvent(), { + maxBytes: text.length * 3 + }) + if (!(result instanceof Promise)) { + throw new Error('Expected clipboard read handler to return a Promise') + } + const isSettled = trackPromiseSettled(result) + + await Promise.resolve() + + expect(isSettled()).toBe(false) + await vi.runOnlyPendingTimersAsync() + await expect(result).resolves.toBe(text) + }) + + it('yields before writing large text clipboard IPC payloads', async () => { + vi.useFakeTimers() + const text = 'é'.repeat(300_000) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + const result = handlers.get('clipboard:writeText')?.(makeClipboardEvent(), text) + if (!(result instanceof Promise)) { + throw new Error('Expected clipboard write handler to return a Promise') + } + const isSettled = trackPromiseSettled(result) + + await Promise.resolve() + + expect(isSettled()).toBe(false) + expect(clipboardWriteTextMock).not.toHaveBeenCalled() + await vi.runOnlyPendingTimersAsync() + await result + expect(clipboardWriteTextMock).toHaveBeenCalledWith(text) + }) + + it('rejects oversized text clipboard IPC writes before calling Electron clipboard', async () => { + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:writeText')?.( + makeClipboardEvent(), + 'copied-secret-token-value'.repeat(900_000) + ) + ).rejects.toThrow('Clipboard text is too large to copy safely.') + await expect( + handlers.get('clipboard:writeSelectionText')?.( + makeClipboardEvent(), + 'selection-secret-token-value'.repeat(900_000) + ) + ).rejects.toThrow('Clipboard text is too large to copy safely.') + expect(clipboardWriteTextMock).not.toHaveBeenCalled() + }) + it('removes stale clipboard IPC handlers before registering replacements', () => { registerClipboardHandlers() @@ -137,6 +313,7 @@ describe('registerClipboardHandlers', () => { 'orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' ) clipboardReadImageMock.mockReturnValue({ + getSize: () => ({ height: 1, width: 1 }), isEmpty: () => false, toPNG: () => png }) @@ -144,9 +321,9 @@ describe('registerClipboardHandlers', () => { registerClipboardHandlers() const handlers = getRegisteredHandlers() - await expect(handlers.get('clipboard:saveImageAsTempFile')?.({}, undefined)).resolves.toBe( - expectedPath - ) + await expect( + handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), undefined) + ).resolves.toBe(expectedPath) expect(fsWriteFileMock).toHaveBeenCalledWith(expectedPath, png) expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) @@ -156,6 +333,7 @@ describe('registerClipboardHandlers', () => { const writeFileBase64 = vi.fn().mockResolvedValue(undefined) const getTempDir = vi.fn().mockResolvedValue('/var/tmp') clipboardReadImageMock.mockReturnValue({ + getSize: () => ({ height: 1, width: 1 }), isEmpty: () => false, toPNG: () => png }) @@ -165,7 +343,9 @@ describe('registerClipboardHandlers', () => { const handlers = getRegisteredHandlers() await expect( - handlers.get('clipboard:saveImageAsTempFile')?.({}, { connectionId: 'ssh-1' }) + handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), { + connectionId: 'ssh-1' + }) ).resolves.toBe('/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png') expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('ssh-1') expect(getTempDir).toHaveBeenCalled() @@ -180,6 +360,7 @@ describe('registerClipboardHandlers', () => { const png = Buffer.from([0, 1, 2, 3]) const writeFileBase64 = vi.fn().mockResolvedValue(undefined) clipboardReadImageMock.mockReturnValue({ + getSize: () => ({ height: 1, width: 1 }), isEmpty: () => false, toPNG: () => png }) @@ -192,7 +373,9 @@ describe('registerClipboardHandlers', () => { const handlers = getRegisteredHandlers() await expect( - handlers.get('clipboard:saveImageAsTempFile')?.({}, { connectionId: 'ssh-1' }) + handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), { + connectionId: 'ssh-1' + }) ).resolves.toBe( 'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' ) @@ -201,4 +384,71 @@ describe('registerClipboardHandlers', () => { png.toString('base64') ) }) + + it('rejects oversized clipboard image dimensions before PNG conversion', async () => { + const toPNG = vi.fn(() => Buffer.from([0, 1, 2, 3])) + clipboardReadImageMock.mockReturnValue({ + getSize: () => ({ height: 1, width: CLIPBOARD_IMAGE_MAX_PIXELS + 1 }), + isEmpty: () => false, + toPNG + }) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), undefined) + ).rejects.toThrow('Clipboard image is too large') + expect(toPNG).not.toHaveBeenCalled() + expect(fsWriteFileMock).not.toHaveBeenCalled() + expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + }) + + it('rejects oversized clipboard PNG bytes before SSH provider lookup', async () => { + clipboardReadImageMock.mockReturnValue({ + getSize: () => ({ height: 1, width: 1 }), + isEmpty: () => false, + toPNG: () => Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1) + }) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), { + connectionId: 'ssh-secret' + }) + ).rejects.toThrow('Clipboard image is too large') + expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + expect(fsWriteFileMock).not.toHaveBeenCalled() + }) + + it('ignores oversized clipboard write-image data before decoding base64', () => { + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + const dataUrl = [ + 'data:image/png;base64,', + 'A'.repeat(CLIPBOARD_IMAGE_MAX_BASE64_CHARS + 1) + ].join('') + handlers.get('clipboard:writeImage')?.(makeClipboardEvent(), dataUrl) + + expect(nativeImageCreateFromBufferMock).not.toHaveBeenCalled() + expect(clipboardWriteImageMock).not.toHaveBeenCalled() + }) + + it('ignores clipboard write images with oversized decoded dimensions', () => { + nativeImageCreateFromBufferMock.mockReturnValue({ + getSize: () => ({ height: 1, width: CLIPBOARD_IMAGE_MAX_PIXELS + 1 }), + isEmpty: () => false + }) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + handlers.get('clipboard:writeImage')?.(makeClipboardEvent(), 'data:image/png;base64,AAAA') + + expect(nativeImageCreateFromBufferMock).toHaveBeenCalled() + expect(clipboardWriteImageMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index c6b849b08ed..233c6870ed5 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -1,8 +1,30 @@ -import { clipboard, ipcMain, nativeImage } from 'electron' +import { + clipboard, + ipcMain, + nativeImage, + type IpcMainInvokeEvent, + type WebContents +} from 'electron' +import { + assertClipboardTextWriteWithinLimitWithYield, + assertClipboardTextWithinLimitWithYield, + type ReadClipboardTextOptions +} from '../../shared/clipboard-text' import { saveClipboardImageBufferAsTempFile, type SaveClipboardImageAsTempFileArgs } from './clipboard-image-temp-file' +import { + assertClipboardImageBase64LengthWithinLimit, + assertClipboardImageByteLengthWithinLimit, + assertClipboardImageDimensionsWithinLimit +} from '../../shared/clipboard-image' + +let trustedClipboardRendererWebContentsId: number | null = null + +export function setTrustedClipboardRendererWebContentsId(webContentsId: number | null): void { + trustedClipboardRendererWebContentsId = webContentsId +} export function registerClipboardHandlers(): void { ipcMain.removeHandler('clipboard:readText') @@ -12,26 +34,45 @@ export function registerClipboardHandlers(): void { ipcMain.removeHandler('clipboard:writeImage') ipcMain.removeHandler('clipboard:saveImageAsTempFile') - ipcMain.handle('clipboard:readText', () => clipboard.readText()) - ipcMain.handle('clipboard:readSelectionText', () => clipboard.readText('selection')) + ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => { + assertTrustedClipboardSender(event) + return assertClipboardTextWithinLimitWithYield(clipboard.readText(), options) + }) + ipcMain.handle( + 'clipboard:readSelectionText', + async (event, options?: ReadClipboardTextOptions) => { + assertTrustedClipboardSender(event) + return assertClipboardTextWithinLimitWithYield(clipboard.readText('selection'), options) + } + ) // Why: terminals need to detect clipboard images to support tools like Claude // Code that accept image input via paste. Writes the clipboard image to a // temp file and returns the path, or null if the clipboard has no image. ipcMain.handle( 'clipboard:saveImageAsTempFile', - async (_event, args?: SaveClipboardImageAsTempFileArgs) => { + async (event, args?: SaveClipboardImageAsTempFileArgs) => { + assertTrustedClipboardSender(event) const image = clipboard.readImage() if (image.isEmpty()) { return null } + assertClipboardImageDimensionsWithinLimit(image.getSize()) return saveClipboardImageBufferAsTempFile(image.toPNG(), args) } ) - ipcMain.handle('clipboard:writeText', (_event, text: string) => clipboard.writeText(text)) - ipcMain.handle('clipboard:writeSelectionText', (_event, text: string) => - clipboard.writeText(text, 'selection') - ) - ipcMain.handle('clipboard:writeImage', (_event, dataUrl: string) => { + ipcMain.handle('clipboard:writeText', async (event, text: string) => { + assertTrustedClipboardSender(event) + return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text)) + }) + ipcMain.handle('clipboard:writeSelectionText', async (event, text: string) => { + assertTrustedClipboardSender(event) + return clipboard.writeText( + await assertClipboardTextWriteWithinLimitWithYield(text), + 'selection' + ) + }) + ipcMain.handle('clipboard:writeImage', (event, dataUrl: string) => { + assertTrustedClipboardSender(event) // Why: only accept validated PNG data URIs to prevent writing arbitrary // data to the clipboard. The renderer already validates the prefix, but // defense-in-depth applies here too. @@ -39,15 +80,57 @@ export function registerClipboardHandlers(): void { if (typeof dataUrl !== 'string' || !dataUrl.startsWith(prefix)) { return } + const contentBase64 = dataUrl.slice(prefix.length) + try { + assertClipboardImageBase64LengthWithinLimit(contentBase64.length) + } catch { + return + } // Why: use createFromBuffer instead of createFromDataURL — the latter // silently returns an empty image on some macOS + Electron combinations // when the data URL is large (>500KB). Decoding the base64 manually and // using createFromBuffer is more reliable. - const buffer = Buffer.from(dataUrl.slice(prefix.length), 'base64') + const buffer = Buffer.from(contentBase64, 'base64') + try { + assertClipboardImageByteLengthWithinLimit(buffer.byteLength) + } catch { + return + } const image = nativeImage.createFromBuffer(buffer) if (image.isEmpty()) { return } + try { + assertClipboardImageDimensionsWithinLimit(image.getSize()) + } catch { + return + } clipboard.writeImage(image) }) } + +function assertTrustedClipboardSender(event: IpcMainInvokeEvent): void { + if (!isTrustedClipboardRenderer(event.sender)) { + throw new Error('Unauthorized clipboard IPC sender') + } +} + +function isTrustedClipboardRenderer(sender: WebContents): boolean { + if (sender.isDestroyed() || sender.getType() !== 'window') { + return false + } + if (trustedClipboardRendererWebContentsId != null) { + return sender.id === trustedClipboardRendererWebContentsId + } + + const senderUrl = sender.getURL() + if (process.env.ELECTRON_RENDERER_URL) { + try { + return new URL(senderUrl).origin === new URL(process.env.ELECTRON_RENDERER_URL).origin + } catch { + return false + } + } + + return senderUrl.startsWith('file://') +} diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 4cf418f14f3..dec78fea5ac 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -40,6 +40,7 @@ import { } from '../../shared/keybindings' import { getMainE2EConfig } from '../e2e-config' import { buildEditableContextMenuTemplate } from './editable-context-menu' +import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui' function forceRepaint(window: BrowserWindow): void { if (window.isDestroyed()) { @@ -293,6 +294,9 @@ export function createMainWindow( } }) const rendererWebContentsId = mainWindow.webContents.id + // Why: native paste fallback is privileged IPC; only the real top-level + // renderer should be allowed to request Electron's native paste operation. + setTrustedUIRendererWebContentsId(rendererWebContentsId) if (process.platform === 'darwin') { // Why: persistent browser webviews use separate compositor layers, and on @@ -1129,6 +1133,7 @@ export function createMainWindow( ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused) ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused) ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused) + clearTrustedUIRendererWebContentsId(rendererWebContentsId) // Why: on updater-triggered shutdown, BrowserWindow can emit `closed` // after its webContents has already been destroyed. The destroyed // webContents owns its listeners, so do not touch `mainWindow.webContents` diff --git a/src/main/window/editable-context-menu.test.ts b/src/main/window/editable-context-menu.test.ts index 35b59785801..d56de522b77 100644 --- a/src/main/window/editable-context-menu.test.ts +++ b/src/main/window/editable-context-menu.test.ts @@ -92,7 +92,7 @@ describe('buildEditableContextMenuTemplate', () => { 'separator', 'cut', 'copy', - 'paste', + 'Paste', 'Paste as plain text', 'selectAll' ]) @@ -115,9 +115,14 @@ describe('buildEditableContextMenuTemplate', () => { x: 12, y: 34 }) + + template[8].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + template[9].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: false }) + expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: true }) }) - it('does not build a menu outside editable misspelled text', () => { + it('does not build a menu outside editable text', () => { const webContents = { replaceMisspelling: vi.fn(), send: vi.fn(), @@ -127,24 +132,44 @@ describe('buildEditableContextMenuTemplate', () => { expect( buildEditableContextMenuTemplate(contextParams({ isEditable: false }), webContents) ).toEqual([]) - expect( - buildEditableContextMenuTemplate( - contextParams({ - formControlType: 'input-text', - misspelledWord: '', - dictionarySuggestions: [] - }), - webContents - ) - ).toEqual([]) + }) + + it('keeps coordinated paste available for regular editable text without spelling actions', () => { + const send = vi.fn() + const template = buildEditableContextMenuTemplate( + contextParams({ + formControlType: 'input-text', + misspelledWord: '', + dictionarySuggestions: [] + }), + { + replaceMisspelling: vi.fn(), + send, + session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session + } + ) + + expect(template.map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'cut', + 'copy', + 'Paste', + 'Paste as plain text', + 'selectAll' + ]) + + template[2].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + template[3].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: false }) + expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: true }) }) it('keeps regular text inputs to spelling and native edit actions', () => { + const send = vi.fn() const template = buildEditableContextMenuTemplate( contextParams({ formControlType: 'input-text' }), { replaceMisspelling: vi.fn(), - send: vi.fn(), + send, session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session } ) @@ -157,9 +182,14 @@ describe('buildEditableContextMenuTemplate', () => { 'separator', 'cut', 'copy', - 'paste', + 'Paste', 'Paste as plain text', 'selectAll' ]) + + template[7].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + template[8].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: false }) + expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: true }) }) }) diff --git a/src/main/window/editable-context-menu.ts b/src/main/window/editable-context-menu.ts index f7f60e95f85..b4cb8e42b68 100644 --- a/src/main/window/editable-context-menu.ts +++ b/src/main/window/editable-context-menu.ts @@ -24,6 +24,24 @@ function markdownCommandItem( } } +function editableContextPasteItem( + label: string, + webContents: EditableContextMenuWebContents, + options?: { plainTextOnly?: boolean } +): Electron.MenuItemConstructorOptions { + return { + label, + click: () => { + // Why: context-menu paste must share renderer ownership with keyboard and + // app-menu paste so large text controls can chunk and terminals cannot + // receive duplicate native paste. + webContents.send('ui:editableContextPaste', { + plainTextOnly: options?.plainTextOnly === true + }) + } + } +} + function buildMarkdownMenuTemplate( webContents: EditableContextMenuWebContents, point: { x: number; y: number } @@ -67,18 +85,20 @@ function buildMarkdownMenuTemplate( { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, - { role: 'paste' }, - { role: 'pasteAndMatchStyle', label: 'Paste as plain text' }, + editableContextPasteItem('Paste', webContents), + editableContextPasteItem('Paste as plain text', webContents, { plainTextOnly: true }), { role: 'selectAll' } ] } -function buildNativeEditMenuTemplate(): Electron.MenuItemConstructorOptions[] { +function buildNativeEditMenuTemplate( + webContents: EditableContextMenuWebContents +): Electron.MenuItemConstructorOptions[] { return [ { role: 'cut' }, { role: 'copy' }, - { role: 'paste' }, - { role: 'pasteAndMatchStyle', label: 'Paste as plain text' }, + editableContextPasteItem('Paste', webContents), + editableContextPasteItem('Paste as plain text', webContents, { plainTextOnly: true }), { role: 'selectAll' } ] } @@ -93,10 +113,6 @@ export function buildEditableContextMenuTemplate( const suggestions = params.dictionarySuggestions.slice(0, 5) const isRichMarkdownSurface = params.formControlType === 'none' - if (!isRichMarkdownSurface && suggestions.length === 0 && !params.misspelledWord) { - return [] - } - const template: Electron.MenuItemConstructorOptions[] = suggestions.map((suggestion) => ({ label: suggestion, click: () => webContents.replaceMisspelling(suggestion) @@ -120,7 +136,7 @@ export function buildEditableContextMenuTemplate( template.push( ...(isRichMarkdownSurface ? buildMarkdownMenuTemplate(webContents, { x: params.x, y: params.y }) - : buildNativeEditMenuTemplate()) + : buildNativeEditMenuTemplate(webContents)) ) return template diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3acbc565daa..8c07f5bb289 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,7 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { TaskSourceContext } from '../shared/task-source-context' @@ -2356,6 +2357,9 @@ export type PreloadApi = { onCtrlTabKeyUp: (callback: () => void) => () => void onToggleStatusBar: (callback: () => void) => () => void onDictationKeyDown: (callback: () => void) => () => void + onExportPdfRequested: (callback: () => void) => () => void + onAppMenuPaste: (callback: () => void) => () => void + onEditableContextPaste: (callback: (data: { plainTextOnly: boolean }) => void) => () => void onActivateWorktree: ( callback: (data: { repoId: string @@ -2455,14 +2459,15 @@ export type PreloadApi = { ) => () => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void - readClipboardText: () => Promise - readSelectionClipboardText: () => Promise + readClipboardText: (options?: ReadClipboardTextOptions) => Promise + readSelectionClipboardText: (options?: ReadClipboardTextOptions) => Promise saveClipboardImageAsTempFile: (args?: { connectionId?: string | null }) => Promise writeClipboardText: (text: string) => Promise writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise + performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void getZoomLevel: () => number setZoomLevel: (level: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index c0a35cf2bf4..a9982eb0b04 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -161,9 +161,11 @@ import { ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' import { - NATIVE_FILE_DROP_TARGET, ORCA_INTERNAL_FILE_DRAG_TYPE, + createNativeFileDropPayload, + createRejectedNativeFileDropPayload, hasNativeFileDragTypes, + NATIVE_FILE_DROP_MAX_PATHS, resolveNativeFileDropPath, type NativeDropResolution, type NativeFileDropPayload, @@ -172,6 +174,7 @@ import { import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' +import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { CrashReportBreadcrumbData, CrashReportSubmitArgs, @@ -310,7 +313,8 @@ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { pathEntries.push({ nativeFileDropTarget: entry.dataset.nativeFileDropTarget, nativeFileDropDir: entry.dataset.nativeFileDropDir, - terminalTabId: entry.dataset.terminalTabId + terminalTabId: entry.dataset.terminalTabId, + terminalPaneLeafId: entry.dataset.terminalPaneLeafId ?? entry.dataset.leafId }) } } @@ -354,6 +358,21 @@ document.addEventListener( } const resolution = resolveNativeFileDrop(e) + // Why: resolving native File objects to paths is synchronous in preload. + // Reject oversized gestures by count before touching every File object. + if (files.length > NATIVE_FILE_DROP_MAX_PATHS) { + ipcRenderer.send( + 'terminal:file-dropped-from-preload', + createRejectedNativeFileDropPayload({ + byteLength: 0, + pathCount: files.length, + reason: 'too-many-paths', + status: 'rejected' + }) + ) + return + } + const paths: string[] = [] for (let i = 0; i < files.length; i++) { // webUtils.getPathForFile is the Electron 28+ replacement for File.path @@ -374,28 +393,14 @@ document.addEventListener( return } - // Why: preload must emit exactly one native-drop event per drop gesture. - // The preload layer already has the full FileList. Re-emitting one IPC - // message per path and asking the renderer to reconstruct the gesture via - // timing would be both fragile and slower under large drops. - if (resolution?.target === NATIVE_FILE_DROP_TARGET.fileExplorer) { - ipcRenderer.send('terminal:file-dropped-from-preload', { - paths, - target: NATIVE_FILE_DROP_TARGET.fileExplorer, - destinationDir: resolution.destinationDir - }) - } else { - // Why: falls back to 'editor' so drops on surfaces without an explicit - // marker preserve the prior open-in-editor behavior instead of being - // silently discarded. - ipcRenderer.send('terminal:file-dropped-from-preload', { - paths, - target: resolution?.target ?? NATIVE_FILE_DROP_TARGET.editor, - ...(resolution?.target === NATIVE_FILE_DROP_TARGET.terminal && resolution.tabId - ? { tabId: resolution.tabId } - : {}) - }) + const payload = createNativeFileDropPayload(resolution, paths) + if (!payload) { + return } + // Why: preload must emit exactly one native-drop event per drop gesture. + // The shared planner also rejects large path payloads without including + // path contents in the failure event. + ipcRenderer.send('terminal:file-dropped-from-preload', payload) }, true ) @@ -2937,6 +2942,26 @@ const api = { ipcRenderer.on('ui:toggleStatusBar', listener) return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener) }, + onExportPdfRequested: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('export:requestPdf', listener) + return () => ipcRenderer.removeListener('export:requestPdf', listener) + }, + onAppMenuPaste: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:appMenuPaste', listener) + return () => ipcRenderer.removeListener('ui:appMenuPaste', listener) + }, + onEditableContextPaste: ( + callback: (data: { plainTextOnly: boolean }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { plainTextOnly: boolean } + ): void => callback({ plainTextOnly: data?.plainTextOnly === true }) + ipcRenderer.on('ui:editableContextPaste', listener) + return () => ipcRenderer.removeListener('ui:editableContextPaste', listener) + }, onDictationKeyDown: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:dictationKeyDown', listener) @@ -3195,9 +3220,10 @@ const api = { ipcRenderer.on('terminal:zoom', listener) return () => ipcRenderer.removeListener('terminal:zoom', listener) }, - readClipboardText: (): Promise => ipcRenderer.invoke('clipboard:readText'), - readSelectionClipboardText: (): Promise => - ipcRenderer.invoke('clipboard:readSelectionText'), + readClipboardText: (options?: ReadClipboardTextOptions): Promise => + ipcRenderer.invoke('clipboard:readText', options), + readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => + ipcRenderer.invoke('clipboard:readSelectionText', options), saveClipboardImageAsTempFile: (args?: { connectionId?: string | null }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), @@ -3207,6 +3233,11 @@ const api = { ipcRenderer.invoke('clipboard:writeSelectionText', text), writeClipboardImage: (dataUrl: string): Promise => ipcRenderer.invoke('clipboard:writeImage', dataUrl), + performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }): void => { + ipcRenderer.send('ui:performNativePaste', { + mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' + }) + }, onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => subscribeNativeFileDrop(callback), getZoomLevel: (): number => webFrame.getZoomLevel(), diff --git a/src/relay/fs-handler-install-rg.test.ts b/src/relay/fs-handler-install-rg.test.ts new file mode 100644 index 00000000000..edc2424c645 --- /dev/null +++ b/src/relay/fs-handler-install-rg.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' +import { detectLinuxInstallCommandFromOsRelease } from './fs-handler-install-rg' + +describe('detectLinuxInstallCommandFromOsRelease', () => { + it('uses apt for Debian and Ubuntu families', () => { + expect(detectLinuxInstallCommandFromOsRelease('ID=ubuntu')).toBe('sudo apt install ripgrep') + expect(detectLinuxInstallCommandFromOsRelease('ID=pop\nID_LIKE="ubuntu debian"')).toBe( + 'sudo apt install ripgrep' + ) + }) + + it('uses distro-specific commands for common non-Debian families', () => { + expect(detectLinuxInstallCommandFromOsRelease('ID="fedora"')).toBe('sudo dnf install ripgrep') + expect(detectLinuxInstallCommandFromOsRelease('ID=manjaro\r\nID_LIKE="arch"')).toBe( + 'sudo pacman -S ripgrep' + ) + expect(detectLinuxInstallCommandFromOsRelease("ID=postmarketos\nID_LIKE='alpine'")).toBe( + 'sudo apk add ripgrep' + ) + }) + + it('falls back to generic Linux package-manager guidance', () => { + expect(detectLinuxInstallCommandFromOsRelease('ID=unknown')).toBe( + 'install ripgrep via your package manager (e.g. apt/dnf/pacman)' + ) + }) + + it('does not use whitespace regex splitting for ID_LIKE parsing', () => { + const splitSpy = vi.spyOn(String.prototype, 'split') + + expect(detectLinuxInstallCommandFromOsRelease('ID=unknown\nID_LIKE="rhel fedora"')).toBe( + 'sudo dnf install ripgrep' + ) + + const usedWhitespaceFieldSplit = splitSpy.mock.calls.some( + ([separator]) => separator instanceof RegExp && separator.source.includes('\\s+') + ) + splitSpy.mockRestore() + expect(usedWhitespaceFieldSplit).toBe(false) + }) +}) diff --git a/src/relay/fs-handler-install-rg.ts b/src/relay/fs-handler-install-rg.ts index b34bc6deef9..e66d3bb06f0 100644 --- a/src/relay/fs-handler-install-rg.ts +++ b/src/relay/fs-handler-install-rg.ts @@ -1,4 +1,12 @@ import { readFile } from 'fs/promises' +import { + getProcessOutputFields, + iterateProcessOutputLines +} from '../shared/process-output-field-scanner' + +const GENERIC_LINUX_RIPGREP_INSTALL = + 'install ripgrep via your package manager (e.g. apt/dnf/pacman)' +const OS_RELEASE_ID_LIKE_MAX_FIELDS = 16 export async function detectInstallCommand(): Promise { if (process.platform === 'darwin') { @@ -7,31 +15,66 @@ export async function detectInstallCommand(): Promise { if (process.platform === 'linux') { try { const osRelease = await readFile('/etc/os-release', 'utf-8') - const idMatch = osRelease.match(/^ID=(?:"?)([^"\n]+)(?:"?)$/m) - const idLikeMatch = osRelease.match(/^ID_LIKE=(?:"?)([^"\n]+)(?:"?)$/m) - const ids = [idMatch?.[1], ...(idLikeMatch?.[1]?.split(/\s+/) ?? [])].filter(Boolean) - for (const id of ids) { - if (id === 'debian' || id === 'ubuntu') { - return 'sudo apt install ripgrep' - } - if (id === 'fedora' || id === 'rhel' || id === 'centos') { - return 'sudo dnf install ripgrep' - } - if (id === 'arch') { - return 'sudo pacman -S ripgrep' - } - if (id === 'alpine') { - return 'sudo apk add ripgrep' - } - } + return detectLinuxInstallCommandFromOsRelease(osRelease) } catch { /* fall through to generic guidance */ } - return 'install ripgrep via your package manager (e.g. apt/dnf/pacman)' + return GENERIC_LINUX_RIPGREP_INSTALL } return 'install ripgrep (https://github.com/BurntSushi/ripgrep#installation)' } +export function detectLinuxInstallCommandFromOsRelease(osRelease: string): string { + for (const id of getOsReleasePackageFamilyIds(osRelease)) { + if (id === 'debian' || id === 'ubuntu') { + return 'sudo apt install ripgrep' + } + if (id === 'fedora' || id === 'rhel' || id === 'centos') { + return 'sudo dnf install ripgrep' + } + if (id === 'arch') { + return 'sudo pacman -S ripgrep' + } + if (id === 'alpine') { + return 'sudo apk add ripgrep' + } + } + + return GENERIC_LINUX_RIPGREP_INSTALL +} + +function getOsReleasePackageFamilyIds(osRelease: string): string[] { + const ids: string[] = [] + + for (const line of iterateProcessOutputLines(osRelease)) { + const separatorIndex = line.indexOf('=') + if (separatorIndex <= 0) { + continue + } + + const key = line.slice(0, separatorIndex) + const value = readOsReleaseValue(line.slice(separatorIndex + 1)) + if (key === 'ID') { + const id = getProcessOutputFields(value, 1)[0] + if (id) { + ids.push(id) + } + } else if (key === 'ID_LIKE') { + ids.push(...getProcessOutputFields(value, OS_RELEASE_ID_LIKE_MAX_FIELDS)) + } + } + + return ids +} + +function readOsReleaseValue(rawValue: string): string { + const trimmed = rawValue.trim() + const quote = trimmed[0] + return (quote === '"' || quote === "'") && trimmed.at(-1) === quote + ? trimmed.slice(1, -1) + : trimmed +} + export async function buildInstallRgMessage(cause: unknown): Promise { const reason = cause instanceof Error ? cause.message : String(cause) const cmd = await detectInstallCommand() diff --git a/src/relay/git-handler-branch-cleanup.test.ts b/src/relay/git-handler-branch-cleanup.test.ts index 430076e8482..90dcdf003ef 100644 --- a/src/relay/git-handler-branch-cleanup.test.ts +++ b/src/relay/git-handler-branch-cleanup.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import * as path from 'path' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' @@ -15,7 +16,7 @@ function worktreeList(...entries: { path: string; branch?: string }[]): string { } function resolvedRepoPath(): string { - return '/repo' + return path.posix.resolve('/repo-feature', '/repo/.git', '..') } describe('removeWorktreeOp branch cleanup', () => { diff --git a/src/relay/git-handler-commit-diff-ops.ts b/src/relay/git-handler-commit-diff-ops.ts index 780e40d374a..b4fe9fd79d2 100644 --- a/src/relay/git-handler-commit-diff-ops.ts +++ b/src/relay/git-handler-commit-diff-ops.ts @@ -2,6 +2,7 @@ import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-o import { parseBranchDiff } from './git-handler-utils' import { buildDiffResult } from './git-diff-result' import { parseNumstat } from '../shared/git-uncommitted-line-stats' +import { parseGitRevListFirstParentOid } from '../shared/git-rev-list-output' const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ @@ -49,8 +50,8 @@ export async function commitCompare(git: GitExec, worktreePath: string, commitId ['rev-list', '--parents', '-n', '1', commitOid], worktreePath ) - const [, firstParent] = parentsOut.trim().split(/\s+/) - summary.parentOid = firstParent ?? null + const firstParent = parseGitRevListFirstParentOid(parentsOut) + summary.parentOid = firstParent summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' // Why: root commits have no parent tree; diff-tree --root asks git to diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index 684bb5e2bdc..63c0418a752 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import * as path from 'path' import type { GitExec } from './git-handler-ops' import { addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' @@ -15,7 +16,7 @@ function worktreeList(...entries: { path: string; branch?: string }[]): string { } function resolvedRepoPath(): string { - return '/repo' + return path.posix.resolve('/repo-feature', '/repo/.git', '..') } describe('addWorktreeOp', () => { diff --git a/src/relay/git-handler-worktree-paths.test.ts b/src/relay/git-handler-worktree-paths.test.ts index d46d1918300..1b1eabe7e8c 100644 --- a/src/relay/git-handler-worktree-paths.test.ts +++ b/src/relay/git-handler-worktree-paths.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import * as path from 'path' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' @@ -28,7 +29,7 @@ function nulWorktreeList(...entries: { path: string; branch?: string }[]): strin } function resolvedRepoPath(): string { - return '/repo' + return path.posix.resolve('/repo-feature', '/repo/.git', '..') } describe('relay worktree path parsing', () => { diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts index a8648cf281a..9df947b1aad 100644 --- a/src/relay/port-scan-handler.test.ts +++ b/src/relay/port-scan-handler.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { parseHexAddress } from './port-scan-handler' import { parseWindowsNetstatOutput, parseWindowsPowerShellPortRows } from './windows-port-scan' @@ -118,4 +118,20 @@ describe('parseWindowsNetstatOutput', () => { { host: '::1', port: 3000, pid: 5678 } ]) }) + + it('parses Windows netstat rows without whitespace regex splitting', () => { + const splitSpy = vi.spyOn(String.prototype, 'split') + + expect( + parseWindowsNetstatOutput( + ' TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 4242' + ) + ).toEqual([{ host: '127.0.0.1', port: 3000, pid: 4242 }]) + + const usedWhitespaceFieldSplit = splitSpy.mock.calls.some( + ([separator]) => separator instanceof RegExp && separator.source.includes('\\s+') + ) + splitSpy.mockRestore() + expect(usedWhitespaceFieldSplit).toBe(false) + }) }) diff --git a/src/relay/port-scan-handler.ts b/src/relay/port-scan-handler.ts index c347b6bacf8..ee40a3b84ef 100644 --- a/src/relay/port-scan-handler.ts +++ b/src/relay/port-scan-handler.ts @@ -1,4 +1,5 @@ import { readFile, readdir, readlink } from 'fs/promises' +import { getProcessOutputFields } from '../shared/process-output-field-scanner' import type { RelayDispatcher, RequestContext } from './dispatcher' import { scanWindowsListeningPorts } from './windows-port-scan' @@ -105,7 +106,7 @@ export class PortScanHandler { const results: { port: number; host: string; inode: number }[] = [] for (let i = 1; i < lines.length; i++) { - const fields = lines[i].trim().split(/\s+/) + const fields = getProcessOutputFields(lines[i], 10) if (fields.length < 10) { continue } diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index af5ad0d3cc7..46b6a4d1848 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -9,6 +9,7 @@ import { recognizeAgentProcess, recognizeAgentProcessFromCommandLine } from '../shared/agent-process-recognition' +import { getFirstCommandToken } from '../shared/command-token-scanner' import { isShellProcess } from '../shared/shell-process-detection' import { resolveWindowsAgentForegroundProcess, @@ -197,7 +198,7 @@ function candidateScore(row: ProcessRow & { depth: number }): number { } function processCommandToken(command: string): string { - return command.trim().split(/\s+/, 1)[0] ?? '' + return getFirstCommandToken(command) } function candidateMatchesFallbackWrapper(candidate: ProcessRow, fallbackProcess: string): boolean { diff --git a/src/relay/windows-port-scan.ts b/src/relay/windows-port-scan.ts index 0c5c407b4cd..fe1f133723c 100644 --- a/src/relay/windows-port-scan.ts +++ b/src/relay/windows-port-scan.ts @@ -1,5 +1,6 @@ import { execFile } from 'child_process' import { promisify } from 'util' +import { getProcessOutputFields } from '../shared/process-output-field-scanner' import { encodePowerShellCommand } from '../shared/powershell-command-encoding' import type { DetectedPort } from './port-scan-handler' import { buildRelayCommandEnv } from './relay-command-env' @@ -101,7 +102,7 @@ export function parseWindowsNetstatOutput(output: string): DetectedPort[] { const rows: DetectedPort[] = [] for (const line of output.split(/\r?\n/)) { - const fields = line.trim().split(/\s+/) + const fields = getProcessOutputFields(line, 5) if (fields.length < 5 || fields[0].toUpperCase() !== 'TCP') { continue } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2e2ac2f2e53..4c6376d2e40 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -86,6 +86,8 @@ import { resolvePrimarySelectionMiddleClickPaste, usePrimarySelectionPaste } from './hooks/usePrimarySelectionPaste' +import { useAppMenuPaste } from './hooks/useAppMenuPaste' +import { useLargeTextControlPaste } from './hooks/useLargeTextControlPaste' import { canSkipRuntimeMobileSessionSyncKeyBuild, getRuntimeMobileSessionSyncKey, @@ -614,6 +616,8 @@ function App(): React.JSX.Element { settings?.primarySelectionMiddleClickPaste ) usePrimarySelectionPaste(primarySelectionMiddleClickPaste) + useAppMenuPaste() + useLargeTextControlPaste() const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) const petVisible = useAppStore((s) => s.petVisible) const renderPetOverlay = shouldRenderPetOverlay({ diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index f2c12955447..c35ddee07af 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -137,6 +137,7 @@ import { updateCommentCodeContextExpansionState, type CommentCodeContextLineUpdate } from '@/components/comment-code-context-state' +import { getPrCommentCodeContext } from '@/components/github/pr-comment-code-context' import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' @@ -144,11 +145,20 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { + getCommentBodySubmitState, + hasBoundedCommentBodyText +} from '@/lib/comment-body-submit-state' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import { getGitHubPRReviewerRows, - normalizeGitHubReviewerLogins + normalizeGitHubReviewerLogins, + parseGitHubReviewerInputLogins } from '@/components/github-pr-reviewer-display' +import { + filterGitHubPRReviewerCandidates, + getGitHubPRReviewerQueryState +} from '@/components/github/github-pr-reviewer-candidate-filter' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, @@ -584,32 +594,22 @@ function PRReviewersPanel({ ), [localReviewRequests] ) - const reviewerQuery = reviewerInput.trim().replace(/^@/, '').toLowerCase() - const filteredReviewerCandidates = useMemo(() => { - const query = reviewerQuery - return reviewerCandidates - .filter((user) => { - const login = user.login.toLowerCase() - return ( - query.length === 0 || - login.includes(query) || - (user.name ?? '').toLowerCase().includes(query) - ) - }) - .sort((a, b) => { - const aLogin = a.login.toLowerCase() - const bLogin = b.login.toLowerCase() - const aStarts = aLogin.startsWith(query) - const bStarts = bLogin.startsWith(query) - if (aStarts !== bStarts) { - return aStarts ? -1 : 1 - } - return a.login.localeCompare(b.login) - }) - }, [reviewerCandidates, reviewerQuery]) + const reviewerQueryState = useMemo( + () => getGitHubPRReviewerQueryState(reviewerInput), + [reviewerInput] + ) + const reviewerQuery = reviewerQueryState.query + const filteredReviewerCandidates = useMemo( + () => + filterGitHubPRReviewerCandidates({ + candidates: reviewerCandidates, + queryState: reviewerQueryState + }), + [reviewerCandidates, reviewerQueryState] + ) const suggestedReviewerRows = useMemo( () => - reviewerQuery.length === 0 + reviewerQuery.length === 0 && !reviewerQueryState.isTooLarge ? reviewerSeedUsers .filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase())) .filter((user) => user.login.toLowerCase() !== authorLogin) @@ -620,6 +620,7 @@ function PRReviewersPanel({ authorLogin, reviewerCandidatesByLogin, reviewerQuery.length, + reviewerQueryState.isTooLarge, reviewerSeedUsers, selectedReviewerLogins ] @@ -687,7 +688,7 @@ function PRReviewersPanel({ return } const logins = normalizeGitHubReviewerLogins( - requestedLogins ?? reviewerInput.split(/[\s,]+/), + requestedLogins ?? parseGitHubReviewerInputLogins(reviewerInput), selectedReviewerLogins ) if (logins.length === 0) { @@ -1160,44 +1161,6 @@ function isPRFileViewed(file: GitHubPRFile): boolean { return file.viewerViewedState === 'VIEWED' } -function findNearestBraceBlock( - lines: string[], - targetLine: number -): { startLine: number; endLine: number } | null { - const stack: number[] = [] - const ranges: { startLine: number; endLine: number }[] = [] - const targetIndex = targetLine - 1 - - lines.forEach((line, lineIndex) => { - for (const character of line) { - if (character === '{') { - stack.push(lineIndex) - } else if (character === '}') { - const startLine = stack.pop() - if (startLine !== undefined && startLine <= lineIndex) { - ranges.push({ startLine: startLine + 1, endLine: lineIndex + 1 }) - } - } - } - }) - - const containingRange = ranges - .filter((range) => range.startLine - 1 <= targetIndex && targetIndex <= range.endLine - 1) - .sort((a, b) => a.endLine - a.startLine - (b.endLine - b.startLine))[0] - - if (containingRange) { - return containingRange - } - - return ( - ranges - .filter( - (range) => range.startLine - 1 >= targetIndex && range.startLine - 1 - targetIndex <= 8 - ) - .sort((a, b) => a.startLine - b.startLine)[0] ?? null - ) -} - // Why: SWR cache for the work-item details fetch. Reopening the same drawer // pays full IPC + `gh` process startup latency without this; with it, cached // data paints immediately while a background refetch keeps the view honest. @@ -2461,42 +2424,36 @@ function CommentCodeContext({ } const source = contents.modified || contents.original - const lines = source.split(/\r?\n/) + const codeContext = getPrCommentCodeContext({ + source, + line, + startLine, + contextBefore, + contextAfter, + fallbackLines: CODE_CONTEXT_FALLBACK_LINES, + maxBlockLines: CODE_CONTEXT_MAX_BLOCK_LINES + }) + if (!codeContext) { + return null + } + const { + selectedLines, + totalLines, + commentFrom, + commentTo, + from, + to, + blockRange, + shouldUseBlockRange, + canExpandAbove, + canExpandBelow, + canExpandBlock + } = codeContext const language = detectLanguage(comment.path) - const commentFrom = Math.max(1, Math.min(startLine ?? line, line)) - const commentTo = Math.min(lines.length, Math.max(startLine ?? line, line)) - const from = Math.max(1, commentFrom - contextBefore) - const to = Math.min(lines.length, commentTo + contextAfter) - const selectedLines = lines.slice(from - 1, to) - const candidateBlockRange = findNearestBraceBlock(lines, commentFrom) - const candidateBlockLineCount = candidateBlockRange - ? candidateBlockRange.endLine - candidateBlockRange.startLine + 1 - : 0 - const isWholeFileBlock = - candidateBlockRange !== null && - candidateBlockRange.startLine <= 2 && - candidateBlockRange.endLine >= lines.length - 1 - const shouldUseBlockRange = - candidateBlockRange !== null && - !isWholeFileBlock && - candidateBlockLineCount <= CODE_CONTEXT_MAX_BLOCK_LINES - const blockRange = shouldUseBlockRange - ? candidateBlockRange - : { - startLine: Math.max(1, commentFrom - CODE_CONTEXT_FALLBACK_LINES), - endLine: Math.min(lines.length, commentTo + CODE_CONTEXT_FALLBACK_LINES) - } - const canExpandAbove = from > 1 - const canExpandBelow = to < lines.length - const canExpandBlock = blockRange.startLine < from || blockRange.endLine > to const blockTooltip = shouldUseBlockRange ? 'Show surrounding code block' : 'Show nearby code context' - if (selectedLines.length === 0) { - return null - } - return (
@@ -2590,7 +2547,7 @@ function CommentCodeContext({ disabled={!canExpandBelow} onClick={() => setContextAfter((current) => - Math.min(current + CODE_CONTEXT_EXPAND_STEP, lines.length - commentTo) + Math.min(current + CODE_CONTEXT_EXPAND_STEP, totalLines - commentTo) ) } aria-label={translate( @@ -3575,13 +3532,22 @@ function CommentReplyForm({ const mountedRef = useMountedRef() const submit = useCallback(async () => { - const trimmed = body.trim() - if (!trimmed || submitting) { + const bodyState = getCommentBodySubmitState(body) + if (bodyState.status === 'empty' || submitting) { + return + } + if (bodyState.status === 'too-large-leading-whitespace') { + toast.error( + translate( + 'auto.components.GitHubItemDialog.commentTooLarge', + 'Comment is too large to submit safely.' + ) + ) return } setSubmitting(true) try { - const ok = await onSubmit(trimmed) + const ok = await onSubmit(bodyState.body) if (!mountedRef.current) { return } @@ -3594,6 +3560,7 @@ function CommentReplyForm({ } } }, [body, mountedRef, onSubmit, submitting]) + const canSubmitReply = hasBoundedCommentBodyText(body) return (
@@ -3610,7 +3577,7 @@ function CommentReplyForm({ -