From a22717bb35d50fcef57348039bbe0f4a7c4fa714 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 15 May 2026 02:13:37 -0400 Subject: [PATCH] Refactor runtime app architecture (#1878) Co-authored-by: Orca --- .gitignore | 3 + config/electron-builder.config.cjs | 4 + .../expo-two-way-audio/ios/AudioEngine.swift | 116 +- mobile/scripts/test-subscribe.ts | 9 +- mobile/src/transport/protocol-version.ts | 10 +- package.json | 2 +- src/cli/args.ts | 3 +- src/cli/dispatch.ts | 2 + src/cli/format.ts | 28 + src/cli/handlers/core.ts | 27 + src/cli/handlers/environment.ts | 75 + src/cli/handlers/repo.ts | 31 +- src/cli/handlers/terminal.ts | 6 + src/cli/help.ts | 15 + src/cli/index.test.ts | 369 +- src/cli/index.ts | 14 +- src/cli/runtime-client.ts | 1 + src/cli/runtime/client.ts | 137 +- src/cli/runtime/envelope-schema.ts | 67 +- src/cli/runtime/environments.test.ts | 68 + src/cli/runtime/environments.ts | 81 + src/cli/runtime/index.ts | 1 + src/cli/runtime/launch.test.ts | 88 + src/cli/runtime/launch.ts | 89 +- src/cli/runtime/status.ts | 2 +- src/cli/runtime/transport.ts | 6 +- src/cli/runtime/types.ts | 33 +- src/cli/runtime/websocket-transport.test.ts | 237 ++ src/cli/runtime/websocket-transport.ts | 22 + src/cli/selectors.ts | 29 +- src/cli/specs/core.ts | 18 + src/cli/specs/environment.ts | 30 + src/cli/specs/index.ts | 2 + src/main/cli/packaged-cli-assets.test.ts | 19 + src/main/git/status.test.ts | 12 +- src/main/git/status.ts | 13 +- src/main/index.ts | 202 +- .../ipc/filesystem-import-ssh-ops.test.ts | 11 +- src/main/ipc/filesystem-import-ssh.test.ts | 49 +- src/main/ipc/filesystem-import-ssh.ts | 10 +- src/main/ipc/filesystem-import.test.ts | 290 +- src/main/ipc/filesystem-mutations.test.ts | 46 +- src/main/ipc/filesystem-mutations.ts | 350 +- src/main/ipc/filesystem-watcher.test.ts | 31 + src/main/ipc/filesystem-watcher.ts | 12 +- src/main/ipc/mobile.test.ts | 72 + src/main/ipc/mobile.ts | 41 +- src/main/ipc/pty.ts | 35 + src/main/ipc/register-core-handlers.test.ts | 8 + src/main/ipc/register-core-handlers.ts | 2 + .../ipc/runtime-environment-call-queue.ts | 92 + ...runtime-environment-request-connections.ts | 46 + src/main/ipc/runtime-environments.test.ts | 615 +++ src/main/ipc/runtime-environments.ts | 313 ++ src/main/ipc/runtime.test.ts | 32 +- src/main/ipc/runtime.ts | 18 + src/main/ipc/worktree-remote.ts | 4 +- src/main/powershell-osc133-bootstrap.test.ts | 11 +- src/main/powershell-osc133-bootstrap.ts | 10 +- .../providers/ssh-filesystem-provider.test.ts | 167 + src/main/providers/ssh-filesystem-provider.ts | 94 +- src/main/providers/types.ts | 3 + src/main/providers/windows-shell-args.test.ts | 7 +- src/main/repo-worktrees.ts | 8 + src/main/runtime/device-registry.ts | 37 +- src/main/runtime/e2ee-keypair.ts | 7 +- src/main/runtime/mobile-presence-lock.test.ts | 21 + src/main/runtime/orca-runtime-browser.ts | 1455 +++++++ src/main/runtime/orca-runtime-files.test.ts | 112 + src/main/runtime/orca-runtime-files.ts | 918 +++++ src/main/runtime/orca-runtime-git.ts | 308 ++ src/main/runtime/orca-runtime.test.ts | 554 ++- src/main/runtime/orca-runtime.ts | 3586 +++++++++-------- .../orchestration-cli-subprocess.test.ts} | 14 +- ...ime-request-connection.integration.test.ts | 67 + src/main/runtime/rpc/core.ts | 8 + src/main/runtime/rpc/dispatcher.ts | 14 +- src/main/runtime/rpc/e2ee-channel.test.ts | 16 +- src/main/runtime/rpc/e2ee-channel.ts | 13 + src/main/runtime/rpc/e2ee-crypto.ts | 61 +- src/main/runtime/rpc/methods/browser-core.ts | 16 + .../runtime/rpc/methods/browser-schemas.ts | 6 + src/main/runtime/rpc/methods/browser.test.ts | 144 + .../rpc/methods/file-watch-event-batcher.ts | 65 + src/main/runtime/rpc/methods/files.test.ts | 394 ++ src/main/runtime/rpc/methods/files.ts | 286 +- src/main/runtime/rpc/methods/git.test.ts | 157 + src/main/runtime/rpc/methods/git.ts | 175 + src/main/runtime/rpc/methods/github.test.ts | 519 +++ src/main/runtime/rpc/methods/github.ts | 442 ++ .../runtime/rpc/methods/hosted-review.test.ts | 48 + src/main/runtime/rpc/methods/hosted-review.ts | 26 + src/main/runtime/rpc/methods/index.ts | 8 + src/main/runtime/rpc/methods/linear.test.ts | 101 + src/main/runtime/rpc/methods/linear.ts | 136 + src/main/runtime/rpc/methods/notes.test.ts | 148 + src/main/runtime/rpc/methods/notes.ts | 88 +- .../runtime/rpc/methods/orchestration.test.ts | 36 + src/main/runtime/rpc/methods/orchestration.ts | 6 + src/main/runtime/rpc/methods/repo.test.ts | 96 + src/main/runtime/rpc/methods/repo.ts | 94 +- src/main/runtime/rpc/methods/terminal.ts | 642 ++- src/main/runtime/rpc/methods/worktree.test.ts | 64 + src/main/runtime/rpc/methods/worktree.ts | 104 +- src/main/runtime/rpc/streaming.test.ts | 55 + .../runtime/rpc/terminal-multiplex.test.ts | 395 ++ .../rpc/terminal-output-batching.test.ts | 249 ++ src/main/runtime/rpc/terminal-send.test.ts | 104 + .../rpc/terminal-subscribe-buffer.test.ts | 104 + src/main/runtime/rpc/ws-transport.test.ts | 108 +- src/main/runtime/rpc/ws-transport.ts | 84 +- src/main/runtime/runtime-metadata.test.ts | 95 +- src/main/runtime/runtime-metadata.ts | 97 +- src/main/runtime/runtime-relative-paths.ts | 29 + src/main/runtime/runtime-rpc.test.ts | 484 +++ src/main/runtime/runtime-rpc.ts | 245 +- src/main/ssh/sftp-upload.test.ts | 63 + src/main/ssh/sftp-upload.ts | 114 +- src/main/ssh/ssh-relay-session.ts | 16 +- src/preload/api-types.ts | 71 + src/preload/index.ts | 79 + .../runtime-environment-subscriptions.test.ts | 103 + .../runtime-environment-subscriptions.ts | 99 + src/relay/fs-handler.test.ts | 64 + src/relay/fs-handler.ts | 30 +- src/renderer/src/App.tsx | 8 +- src/renderer/src/app-startup-routing.test.ts | 20 + .../src/components/GitHubItemDialog.tsx | 304 +- .../src/components/LinearItemDrawer.tsx | 53 +- src/renderer/src/components/QuickOpen.tsx | 20 +- src/renderer/src/components/TaskPage.tsx | 107 +- .../TelemetryFirstLaunchSurface.tsx | 5 +- src/renderer/src/components/Terminal.tsx | 9 +- .../src/components/WorktreeJumpPalette.tsx | 64 +- .../automations/CreateFromPicker.tsx | 18 +- .../components/browser-pane/BrowserPane.tsx | 524 ++- .../components/editor/CombinedDiffViewer.tsx | 70 +- .../src/components/editor/EditorContent.tsx | 1 + .../src/components/editor/EditorPanel.tsx | 136 +- .../src/components/editor/MarkdownPreview.tsx | 61 +- .../editor/MonacoGutterContextMenu.tsx | 16 +- .../components/editor/RichMarkdownEditor.tsx | 58 +- .../editor/UntitledFileRenameDialog.tsx | 7 +- .../editor/editor-autosave-controller.test.ts | 74 + .../editor/editor-autosave-controller.ts | 24 +- .../editor/rich-markdown-extensions.ts | 8 +- .../src/components/editor/useLinkBubble.ts | 5 +- .../components/editor/useLocalImagePick.ts | 62 +- .../editor/useLocalImageSrc.test.ts | 25 + .../src/components/editor/useLocalImageSrc.ts | 72 +- .../components/editor/useMarkdownDocuments.ts | 48 +- .../FloatingTerminalPanel.tsx | 6 +- .../components/github-project/ProjectCell.tsx | 105 +- .../github-project/ProjectPicker.tsx | 75 +- .../github-project/ProjectViewWrapper.tsx | 36 +- .../slug-dialog/AssigneesEditor.tsx | 51 +- .../github-project/slug-dialog/Comments.tsx | 48 +- .../slug-dialog/LabelsEditor.tsx | 41 +- .../components/github/GitHubRateLimitPill.tsx | 51 +- .../new-workspace/SmartWorkspaceNameField.tsx | 152 +- .../notes/ProjectNotesTabContent.tsx | 42 +- .../components/onboarding/OnboardingFlow.tsx | 6 + .../src/components/onboarding/RepoStep.tsx | 120 +- .../onboarding/use-onboarding-flow.ts | 119 +- .../components/right-sidebar/ChecksPanel.tsx | 20 +- .../components/right-sidebar/FileExplorer.tsx | 2 +- .../right-sidebar/FileExplorerRow.tsx | 22 +- .../components/right-sidebar/NotesPanel.tsx | 18 +- .../components/right-sidebar/PRActions.tsx | 22 +- .../src/components/right-sidebar/Search.tsx | 36 +- .../right-sidebar/SourceControl.tsx | 137 +- .../right-sidebar/git-status-refresh.ts | 9 +- .../right-sidebar/useFileDeletion.ts | 46 +- .../right-sidebar/useFileDuplicate.ts | 18 +- .../right-sidebar/useFileExplorerDragDrop.ts | 13 +- .../right-sidebar/useFileExplorerImport.ts | 28 +- .../useFileExplorerInlineInput.ts | 23 +- .../right-sidebar/useFileExplorerTree.ts | 12 +- .../useFileExplorerWatch.test.ts | 40 + .../right-sidebar/useFileExplorerWatch.ts | 126 +- .../right-sidebar/useGitStatusPolling.test.ts | 8 +- .../right-sidebar/useGitStatusPolling.ts | 6 +- .../src/components/settings/BaseRefPicker.tsx | 21 +- .../settings/ManageSessionsSection.tsx | 36 +- .../settings/RepositoryHooksSection.tsx | 14 +- .../settings/RuntimeEnvironmentsPane.tsx | 413 ++ .../src/components/settings/Settings.tsx | 35 +- .../settings/WorktreeSymlinksSection.tsx | 8 +- .../components/sidebar/AddRepoCreateStep.tsx | 50 +- .../src/components/sidebar/AddRepoDialog.tsx | 144 +- .../src/components/sidebar/AddRepoSteps.tsx | 5 +- .../src/components/sidebar/WorktreeCard.tsx | 5 +- .../sidebar/WorktreeContextMenu.tsx | 11 +- .../src/components/sidebar/WorktreeList.tsx | 11 +- .../status-bar/ResourceUsageStatusSegment.tsx | 83 +- .../src/components/tab-bar/EditorFileTab.tsx | 16 +- .../src/components/tab-bar/TabBar.tsx | 6 +- .../editor-tab-local-open-guard.test.ts | 22 + .../tab-bar/editor-tab-local-open-guard.ts | 13 + .../tab-group/useTabGroupWorkspaceModel.ts | 26 +- .../components/terminal-pane/TerminalPane.tsx | 60 +- .../terminal-pane/pty-connection.test.ts | 74 +- .../terminal-pane/pty-connection.ts | 79 +- .../terminal-pane/pty-transport.test.ts | 220 + .../components/terminal-pane/pty-transport.ts | 286 +- .../remote-runtime-pty-batching.ts | 81 + .../remote-runtime-pty-binary-control.ts | 47 + .../remote-runtime-pty-transport.test.ts | 512 +++ .../remote-runtime-pty-transport.ts | 324 ++ .../terminal-pane/resolve-split-cwd.test.ts | 20 +- .../terminal-pane/resolve-split-cwd.ts | 3 +- .../terminal-drop-handler.test.ts | 166 + .../terminal-pane/terminal-drop-handler.ts | 139 +- .../terminal-link-handlers.test.ts | 150 +- .../terminal-pane/terminal-link-handlers.ts | 70 +- .../use-terminal-pane-lifecycle.ts | 20 +- src/renderer/src/hooks/useComposerState.ts | 353 +- .../src/hooks/useEditorExternalWatch.test.ts | 108 +- .../src/hooks/useEditorExternalWatch.ts | 111 +- .../src/hooks/useGitHubSlugMetadata.ts | 70 +- .../src/hooks/useGlobalFileDrop.test.ts | 20 + src/renderer/src/hooks/useGlobalFileDrop.ts | 82 +- src/renderer/src/hooks/useIpcEvents.ts | 98 +- src/renderer/src/hooks/useIssueMetadata.ts | 157 +- src/renderer/src/lib/agent-paste-draft.ts | 28 +- src/renderer/src/lib/agent-ready-wait.ts | 7 +- .../src/lib/codex-session-restart.test.ts | 68 +- src/renderer/src/lib/codex-session-restart.ts | 7 +- .../src/lib/create-untitled-markdown.test.ts | 89 +- .../src/lib/create-untitled-markdown.ts | 17 +- .../src/lib/ensure-hooks-confirmed.ts | 5 +- .../src/lib/http-link-routing.test.ts | 14 +- src/renderer/src/lib/http-link-routing.ts | 8 +- .../launch-agent-background-session.test.ts | 73 +- .../lib/launch-agent-background-session.ts | 101 +- .../src/lib/launch-work-item-direct.ts | 3 +- .../src/lib/local-path-open-guard.test.ts | 18 + src/renderer/src/lib/local-path-open-guard.ts | 15 + src/renderer/src/lib/new-workspace.ts | 12 +- .../src/lib/open-project-notes-tab.ts | 6 +- src/renderer/src/lib/rename-file.ts | 13 +- src/renderer/src/lib/repo-slug-index.ts | 65 +- src/renderer/src/lib/workspace-session.ts | 3 +- .../src/runtime/mobile-markdown-bridge.ts | 8 +- .../src/runtime/remote-file-client.ts | 22 + .../remote-runtime-terminal-multiplexer.ts | 379 ++ .../runtime-compatibility-test-fixture.ts | 37 + .../src/runtime/runtime-file-client.test.ts | 1186 ++++++ .../src/runtime/runtime-file-client.ts | 860 ++++ .../src/runtime/runtime-git-client.test.ts | 144 + .../src/runtime/runtime-git-client.ts | 340 ++ .../src/runtime/runtime-hooks-client.test.ts | 96 + .../src/runtime/runtime-hooks-client.ts | 66 + .../src/runtime/runtime-linear-client.test.ts | 148 + .../src/runtime/runtime-linear-client.ts | 222 + .../src/runtime/runtime-notes-client.test.ts | 228 ++ .../src/runtime/runtime-notes-client.ts | 229 ++ .../src/runtime/runtime-protocol-compat.ts | 19 + .../src/runtime/runtime-repo-client.ts | 42 + .../src/runtime/runtime-rpc-client.test.ts | 155 + .../src/runtime/runtime-rpc-client.ts | 108 + .../runtime-terminal-inspection.test.ts | 72 + .../runtime/runtime-terminal-inspection.ts | 62 + .../runtime/runtime-terminal-stream.test.ts | 120 + .../src/runtime/runtime-terminal-stream.ts | 163 + src/renderer/src/store/slices/browser.test.ts | 1144 ++---- src/renderer/src/store/slices/browser.ts | 280 +- .../src/store/slices/diffComments.test.ts | 56 + src/renderer/src/store/slices/diffComments.ts | 27 +- src/renderer/src/store/slices/editor.test.ts | 295 +- src/renderer/src/store/slices/editor.ts | 149 +- src/renderer/src/store/slices/github.test.ts | 130 +- src/renderer/src/store/slices/github.ts | 251 +- .../src/store/slices/hosted-review.test.ts | 51 +- .../src/store/slices/hosted-review.ts | 34 +- src/renderer/src/store/slices/linear.ts | 26 +- src/renderer/src/store/slices/repos.test.ts | 256 ++ src/renderer/src/store/slices/repos.ts | 119 +- .../src/store/slices/settings.test.ts | 246 ++ src/renderer/src/store/slices/settings.ts | 236 +- src/renderer/src/store/slices/terminals.ts | 15 +- src/renderer/src/store/slices/ui.ts | 1 + .../src/store/slices/worktrees.test.ts | 169 + src/renderer/src/store/slices/worktrees.ts | 116 +- src/shared/constants.ts | 3 + src/shared/cross-platform-path.test.ts | 26 + src/shared/cross-platform-path.ts | 57 + src/shared/e2ee-crypto.ts | 65 + src/shared/protocol-compat.test.ts | 85 +- src/shared/protocol-compat.ts | 69 +- src/shared/protocol-version.ts | 45 +- src/shared/remote-runtime-client.test.ts | 140 + src/shared/remote-runtime-client.ts | 552 +++ ...e-runtime-request-connection-stale.test.ts | 115 + .../remote-runtime-request-connection.test.ts | 142 + .../remote-runtime-request-connection.ts | 292 ++ src/shared/remote-runtime-request-frames.ts | 98 + .../remote-runtime-request-websocket.ts | 96 + src/shared/runtime-environment-store.test.ts | 47 + src/shared/runtime-environment-store.ts | 166 + src/shared/runtime-environments.ts | 99 + src/shared/runtime-rpc-envelope.ts | 73 + src/shared/runtime-types.ts | 58 +- src/shared/secure-file.ts | 109 + src/shared/terminal-stream-protocol.test.ts | 55 + src/shared/terminal-stream-protocol.ts | 12 +- src/shared/types.ts | 4 + src/shared/workspace-session-schema.ts | 3 +- 308 files changed, 32140 insertions(+), 4464 deletions(-) create mode 100644 src/cli/handlers/environment.ts create mode 100644 src/cli/runtime/environments.test.ts create mode 100644 src/cli/runtime/environments.ts create mode 100644 src/cli/runtime/launch.test.ts create mode 100644 src/cli/runtime/websocket-transport.test.ts create mode 100644 src/cli/runtime/websocket-transport.ts create mode 100644 src/cli/specs/environment.ts create mode 100644 src/main/cli/packaged-cli-assets.test.ts create mode 100644 src/main/ipc/mobile.test.ts create mode 100644 src/main/ipc/runtime-environment-call-queue.ts create mode 100644 src/main/ipc/runtime-environment-request-connections.ts create mode 100644 src/main/ipc/runtime-environments.test.ts create mode 100644 src/main/ipc/runtime-environments.ts create mode 100644 src/main/runtime/orca-runtime-browser.ts create mode 100644 src/main/runtime/orca-runtime-files.test.ts create mode 100644 src/main/runtime/orca-runtime-files.ts create mode 100644 src/main/runtime/orca-runtime-git.ts rename src/{cli/orchestration.subprocess.test.ts => main/runtime/orchestration-cli-subprocess.test.ts} (93%) create mode 100644 src/main/runtime/remote-runtime-request-connection.integration.test.ts create mode 100644 src/main/runtime/rpc/methods/browser.test.ts create mode 100644 src/main/runtime/rpc/methods/file-watch-event-batcher.ts create mode 100644 src/main/runtime/rpc/methods/git.test.ts create mode 100644 src/main/runtime/rpc/methods/git.ts create mode 100644 src/main/runtime/rpc/methods/github.test.ts create mode 100644 src/main/runtime/rpc/methods/github.ts create mode 100644 src/main/runtime/rpc/methods/hosted-review.test.ts create mode 100644 src/main/runtime/rpc/methods/hosted-review.ts create mode 100644 src/main/runtime/rpc/methods/linear.test.ts create mode 100644 src/main/runtime/rpc/methods/linear.ts create mode 100644 src/main/runtime/rpc/methods/notes.test.ts create mode 100644 src/main/runtime/rpc/methods/repo.test.ts create mode 100644 src/main/runtime/rpc/methods/worktree.test.ts create mode 100644 src/main/runtime/rpc/terminal-multiplex.test.ts create mode 100644 src/main/runtime/rpc/terminal-output-batching.test.ts create mode 100644 src/main/runtime/rpc/terminal-send.test.ts create mode 100644 src/main/runtime/rpc/terminal-subscribe-buffer.test.ts create mode 100644 src/main/runtime/runtime-relative-paths.ts create mode 100644 src/main/ssh/sftp-upload.test.ts create mode 100644 src/preload/runtime-environment-subscriptions.test.ts create mode 100644 src/preload/runtime-environment-subscriptions.ts create mode 100644 src/renderer/src/app-startup-routing.test.ts create mode 100644 src/renderer/src/components/editor/useLocalImageSrc.test.ts create mode 100644 src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx create mode 100644 src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts create mode 100644 src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts create mode 100644 src/renderer/src/hooks/useGlobalFileDrop.test.ts create mode 100644 src/renderer/src/lib/local-path-open-guard.test.ts create mode 100644 src/renderer/src/lib/local-path-open-guard.ts create mode 100644 src/renderer/src/runtime/remote-file-client.ts create mode 100644 src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts create mode 100644 src/renderer/src/runtime/runtime-compatibility-test-fixture.ts create mode 100644 src/renderer/src/runtime/runtime-file-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-file-client.ts create mode 100644 src/renderer/src/runtime/runtime-git-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-git-client.ts create mode 100644 src/renderer/src/runtime/runtime-hooks-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-hooks-client.ts create mode 100644 src/renderer/src/runtime/runtime-linear-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-linear-client.ts create mode 100644 src/renderer/src/runtime/runtime-notes-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-notes-client.ts create mode 100644 src/renderer/src/runtime/runtime-protocol-compat.ts create mode 100644 src/renderer/src/runtime/runtime-repo-client.ts create mode 100644 src/renderer/src/runtime/runtime-rpc-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-rpc-client.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-inspection.test.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-inspection.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-stream.test.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-stream.ts create mode 100644 src/renderer/src/store/slices/repos.test.ts create mode 100644 src/renderer/src/store/slices/settings.test.ts create mode 100644 src/shared/cross-platform-path.test.ts create mode 100644 src/shared/cross-platform-path.ts create mode 100644 src/shared/e2ee-crypto.ts create mode 100644 src/shared/remote-runtime-client.test.ts create mode 100644 src/shared/remote-runtime-client.ts create mode 100644 src/shared/remote-runtime-request-connection-stale.test.ts create mode 100644 src/shared/remote-runtime-request-connection.test.ts create mode 100644 src/shared/remote-runtime-request-connection.ts create mode 100644 src/shared/remote-runtime-request-frames.ts create mode 100644 src/shared/remote-runtime-request-websocket.ts create mode 100644 src/shared/runtime-environment-store.test.ts create mode 100644 src/shared/runtime-environment-store.ts create mode 100644 src/shared/runtime-environments.ts create mode 100644 src/shared/runtime-rpc-envelope.ts create mode 100644 src/shared/secure-file.ts diff --git a/.gitignore b/.gitignore index 2afd28d2a43..92b3d28c436 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,9 @@ design-docs/ .context/ .atl/ +# Machine-local agent hook endpoint files may contain auth tokens. +/agent-hooks/ + # Local-only design/planning docs (not checked in) docs/*.md !docs/README*.md diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 30fc98ae895..e1a63218fe7 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -39,6 +39,8 @@ module.exports = { // integration — dependencies inside the asar archive are invisible to // require(). Unpack CLI runtime deps so they resolve from // app.asar.unpacked/node_modules/. + // Why: remote runtime connections use WebSocket + E2EE from the packaged CLI + // before the GUI process starts, so those deps need the same treatment. // Why: sherpa-onnx native bindings (platform-specific subpackages) must be // unpacked because they ship .node addons + .dylib/.so files that cannot be // dlopen()'d from inside the asar archive. @@ -49,6 +51,8 @@ module.exports = { 'out/main/computer-sidecar.js', 'out/main/chunks/**', 'resources/**', + 'node_modules/ws/**', + 'node_modules/tweetnacl/**', 'node_modules/zod/**', 'node_modules/sherpa-onnx*/**' ], diff --git a/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift b/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift index f079c89b7d3..ac259e3794c 100644 --- a/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift +++ b/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift @@ -7,41 +7,41 @@ class AudioEngine { private var engineConfigChangeObserver: Any? private var sessionInterruptionObserver: Any? private var mediaServicesResetObserver: Any? - + public private(set) var voiceIOFormat: AVAudioFormat public private(set) var isRecording = false private var wasRecordingBeforeInterruption = false - + public var onMicDataCallback: ((Data) -> Void)? public var onInputVolumeCallback: ((Float) -> Void)? public var onOutputVolumeCallback: ((Float) -> Void)? public var onAudioInterruptionCallback: ((String) -> Void)? - + private var inputLevelTimer: Timer? private var outputLevelTimer: Timer? - + private var inputBuffer = [Float](repeating: 0, count: 2048) private var outputBuffer = [Float](repeating: 0, count: 2048) private var inputBufferIndex = 0 private var outputBufferIndex = 0 - + private var hasFirstInputBeenDiscarded = false private var discardRecording = false private var discardFirstInputMillis = 2000 - + enum AudioEngineError: Error { case audioFormatError } - + init() throws { avAudioEngine.attach(speechPlayer) - + guard let format = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1) else { throw AudioEngineError.audioFormatError } voiceIOFormat = format print("Voice IO format: \(String(describing: voiceIOFormat))") - + engineConfigChangeObserver = NotificationCenter.default.addObserver( forName: .AVAudioEngineConfigurationChange, object: avAudioEngine, @@ -60,12 +60,12 @@ class AudioEngine { queue: .main) { [weak self] _ in self?.handleMediaServicesWereReset() } - + self.setupAudioSession() self.setup() self.start() } - + deinit { if let observer = engineConfigChangeObserver { NotificationCenter.default.removeObserver(observer) @@ -77,29 +77,29 @@ class AudioEngine { NotificationCenter.default.removeObserver(observer) } } - + func setupAudioSession() { let session = AVAudioSession.sharedInstance() - + do { try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]) } catch { print("Could not set the audio category: \(error.localizedDescription)") } - + do { try session.setPreferredSampleRate(voiceIOFormat.sampleRate) } catch { print("Could not set the preferred sample rate: \(error.localizedDescription)") } - + do { try session.setActive(true) } catch { print("Could not set the audio session as active") } } - + func setup() { let input = avAudioEngine.inputNode do { @@ -108,15 +108,15 @@ class AudioEngine { print("Could not enable voice processing \(error)") return } - + avAudioEngine.inputNode.isVoiceProcessingInputMuted = !isRecording - + let output = avAudioEngine.outputNode let mainMixer = avAudioEngine.mainMixerNode - + avAudioEngine.connect(speechPlayer, to: mainMixer, format: voiceIOFormat) avAudioEngine.connect(mainMixer, to: output, format: voiceIOFormat) - + input.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in // We don't do any input processing (no volume calculation or passing mic data to the callback) if discardRecording == true // See comment in the playPCMData function @@ -125,48 +125,48 @@ class AudioEngine { self?.updateInputVolume() } } - + mainMixer.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in self?.processOutputBuffer(buffer) self?.updateOutputVolume() } - + avAudioEngine.prepare() } - + func processMicrophoneBuffer(_ buffer: AVAudioPCMBuffer) { guard let channelData = buffer.floatChannelData?[0] else { print("Error: Could not access channel data") return } - + let frameCount = Int(buffer.frameLength) var int16Samples = [Int16](repeating: 0, count: frameCount) - + // Convert float samples to Int16 and update input buffer for volume calculation for i in 0...size) - + // Send the data to the callback onMicDataCallback?(data) } - + func processOutputBuffer(_ buffer: AVAudioPCMBuffer) { guard let channelData = buffer.floatChannelData?[0] else { print("Error: Could not access channel data") return } - + let frameCount = Int(buffer.frameLength) - + // Update output buffer for volume calculation for i in 0.. AVAudioPCMBuffer? { let frameCount = UInt32(data.count) / 2 // 16-bit input = 2 bytes per frame - + let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 16000, channels: 1, interleaved: false)! - + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return nil } - + buffer.frameLength = frameCount - + data.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) in if let sourcePtr = rawBufferPointer.baseAddress?.assumingMemoryBound(to: Int16.self), let destPtr = buffer.floatChannelData?[0] { @@ -231,15 +231,15 @@ class AudioEngine { } } } - + return buffer } - + func bypassVoiceProcessing(_ bypass: Bool) { let input = avAudioEngine.inputNode input.isVoiceProcessingBypassed = bypass } - + func toggleRecording(_ val: Bool) -> Bool { isRecording = val if !isRecording { @@ -251,10 +251,10 @@ class AudioEngine { avAudioEngine.inputNode.isVoiceProcessingInputMuted = false } print("Recording \(isRecording ? "started" : "stopped")") - + return isRecording } - + func stopRecordingAndPlayer(clearInterruptionResume: Bool = true){ if clearInterruptionResume { wasRecordingBeforeInterruption = false @@ -268,7 +268,7 @@ class AudioEngine { speechPlayer.stop() updateOutputVolume() } - + func resumeRecordingAndPlayer(){ do { try AVAudioSession.sharedInstance().setActive(true) @@ -279,12 +279,12 @@ class AudioEngine { isRecording = toggleRecording(true) speechPlayer.play() } - + func tearDown() { stopRecordingAndPlayer() avAudioEngine.stop() } - + var isPlaying: Bool { return speechPlayer.isPlaying } @@ -308,13 +308,13 @@ class AudioEngine { print("Playback resumed") } } - + private func checkEngineIsRunning() { if !avAudioEngine.isRunning { start() } } - + private func handleAudioSessionInterruption(_ notification: Notification) { guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, @@ -343,39 +343,39 @@ class AudioEngine { fatalError("Unknown type: \(type)") } } - + private func handleMediaServicesWereReset() { self.avAudioEngine.stop() self.setup() self.start() } - + private func updateInputVolume() { let volume = calculateRMSLevel(from: inputBuffer) onInputVolumeCallback?(volume) } - + private func updateOutputVolume() { let volume = calculateRMSLevel(from: outputBuffer) onOutputVolumeCallback?(volume) } - + private func calculateRMSLevel(from buffer: [Float]) -> Float { let epsilon: Float = 1e-5 // To avoid log(0) let rmsValue = sqrt(buffer.reduce(0) { $0 + $1 * $1 } / Float(buffer.count)) - + // Convert to decibels let dbValue = 20 * log10(max(rmsValue, epsilon)) - + // Normalize decibel value to 0-1 range // Assuming minimum audible is -60dB and maximum is 0dB let minDb: Float = -80.0 let normalizedValue = max(0.0, min(1.0, (dbValue - minDb) / abs(minDb))) - + // Optional: Apply exponential factor to push smaller values down let expFactor: Float = 2.0 // Adjust this value to change the curve let adjustedValue = pow(normalizedValue, expFactor) - + return adjustedValue } } diff --git a/mobile/scripts/test-subscribe.ts b/mobile/scripts/test-subscribe.ts index 6fa8ffe4b42..a33ddd0c22b 100644 --- a/mobile/scripts/test-subscribe.ts +++ b/mobile/scripts/test-subscribe.ts @@ -267,7 +267,14 @@ ws.on('open', () => { }) ws.on('message', (data) => { - const plaintext = decrypt(data.toString()) + let plaintext: string | null = null + try { + plaintext = decrypt(data.toString()) + } catch { + // Plaintext handshake/control frames such as e2ee_ready are handled by the + // connect flow above. The global listener only cares about encrypted RPC. + return + } if (!plaintext) return const response = JSON.parse(plaintext) as RpcResponse if (response._meta?.runtimeId) { diff --git a/mobile/src/transport/protocol-version.ts b/mobile/src/transport/protocol-version.ts index 3ba4ede3878..4c1cf137738 100644 --- a/mobile/src/transport/protocol-version.ts +++ b/mobile/src/transport/protocol-version.ts @@ -1,20 +1,20 @@ -// Why: declares the mobile's pairing protocol version and the minimum -// desktop version it can talk to. Duplicates the desktop's +// Why: declares the mobile client's runtime protocol version and the minimum +// server protocol it can talk to. Duplicates the desktop's // `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve // outside `mobile/`. Manual sync is acceptable — these constants are // expected to bump less than once a quarter. // // Bump MOBILE_PROTOCOL_VERSION when: // - You change the meaning of an RPC mobile sends. -// - You stop relying on a desktop-side feature in a way old desktops +// - You stop relying on a server-side feature in a way old servers // would notice. // Do NOT bump for: // - Adding new optional fields to outbound requests. // - Reading new optional fields on incoming responses. // // Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a -// desktop feature added at a specific desktop protocol version. This -// triggers a hard-block screen for users paired to older desktops. +// server feature added at a specific runtime protocol version. This +// triggers a hard-block screen for users paired to older servers. export const MOBILE_PROTOCOL_VERSION = 2 export const MIN_COMPATIBLE_DESKTOP_VERSION = 2 diff --git a/package.json b/package.json index 39e335bc753..5da12543545 100644 --- a/package.json +++ b/package.json @@ -88,8 +88,8 @@ "cmdk": "^1.1.1", "dompurify": "^3.4.2", "electron-updater": "^6.8.3", - "entities": "^6.0.1", "emoji-picker-react": "^4.19.1", + "entities": "^6.0.1", "github-slugger": "^2.0.0", "hosted-git-info": "^9.0.3", "html-to-image": "^1.11.13", diff --git a/src/cli/args.ts b/src/cli/args.ts index ece4215d8f5..9af56d69065 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -14,7 +14,7 @@ export type CommandSpec = { notes?: string[] } -export const GLOBAL_FLAGS = ['help', 'json'] +export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment'] export function parseArgs(argv: string[]): ParsedArgs { const commandPath: string[] = [] @@ -93,6 +93,7 @@ export function isCommandGroup(commandPath: string[]): boolean { 'storage', 'orchestration', 'computer', + 'environment', 'note' ].includes(commandPath[0])) || (commandPath.length === 2 && diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 1937a4593ad..00896007067 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -14,6 +14,7 @@ import { BROWSER_ENV_HANDLERS } from './handlers/browser-env' import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' import { COMPUTER_HANDLERS } from './handlers/computer' +import { ENVIRONMENT_HANDLERS } from './handlers/environment' import { NOTE_HANDLERS } from './handlers/note' export type HandlerContext = { @@ -42,6 +43,7 @@ function buildHandlers(): Map { BROWSER_STORAGE_HANDLERS, ORCHESTRATION_HANDLERS, COMPUTER_HANDLERS, + ENVIRONMENT_HANDLERS, NOTE_HANDLERS ] for (const group of groups) { diff --git a/src/cli/format.ts b/src/cli/format.ts index b8cce4771af..0b99b8bcae4 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -33,6 +33,7 @@ import type { RuntimeWorktreePsResult, RuntimeWorktreeRecord } from '../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../shared/notes-types' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' @@ -101,6 +102,33 @@ export function formatStatus(status: CliStatusResult): string { return formatCliStatus(status) } +export function formatEnvironmentList(result: { + environments: PublicKnownRuntimeEnvironment[] +}): string { + if (result.environments.length === 0) { + return 'No saved environments.' + } + return result.environments + .map( + (environment) => + `${environment.id} ${environment.name} ${environment.endpoints[0]?.endpoint ?? 'no-endpoint'}` + ) + .join('\n') +} + +export function formatEnvironment(environment: PublicKnownRuntimeEnvironment): string { + return [ + `id: ${environment.id}`, + `name: ${environment.name}`, + `runtimeId: ${environment.runtimeId ?? 'unknown'}`, + `lastUsedAt: ${environment.lastUsedAt ?? 'never'}`, + `preferredEndpointId: ${environment.preferredEndpointId}`, + ...environment.endpoints.map( + (endpoint) => `endpoint: ${endpoint.id} ${endpoint.kind} ${endpoint.endpoint}` + ) + ].join('\n') +} + export function formatTerminalList(result: RuntimeTerminalListResult): string { if (result.terminals.length === 0) { return 'No live terminals.' diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index f96a941daf9..895aa7abca8 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -1,11 +1,38 @@ import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' +import { RuntimeClientError, serveOrcaApp } from '../runtime-client' export const CORE_HANDLERS: Record = { open: async ({ client, json }) => { const result = await client.openOrca() printResult(result, json, formatCliStatus) }, + serve: async ({ flags, json }) => { + if (flags.get('no-pairing') === true && flags.get('mobile-pairing') === true) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --mobile-pairing or --no-pairing, not both.' + ) + } + const rawPort = flags.get('port') + if (typeof rawPort === 'string') { + const port = Number(rawPort) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new RuntimeClientError('invalid_argument', `Invalid --port value: ${rawPort}`) + } + } + const exitCode = await serveOrcaApp({ + json, + port: typeof rawPort === 'string' ? rawPort : null, + pairingAddress: + typeof flags.get('pairing-address') === 'string' + ? (flags.get('pairing-address') as string) + : null, + noPairing: flags.get('no-pairing') === true, + mobilePairing: flags.get('mobile-pairing') === true + }) + process.exitCode = exitCode + }, status: async ({ client, json }) => { const result = await client.getCliStatus() if (!json && !result.result.runtime.reachable) { diff --git a/src/cli/handlers/environment.ts b/src/cli/handlers/environment.ts new file mode 100644 index 00000000000..08530ea29c3 --- /dev/null +++ b/src/cli/handlers/environment.ts @@ -0,0 +1,75 @@ +import type { CommandHandler } from '../dispatch' +import { formatEnvironment, formatEnvironmentList, printResult } from '../format' +import { getDefaultUserDataPath } from '../runtime-client' +import type { RuntimeRpcSuccess } from '../runtime-client' +import { RuntimeClientError } from '../runtime-client' +import { redactRuntimeEnvironment } from '../../shared/runtime-environments' +import { + addEnvironmentFromPairingCode, + listEnvironments, + removeEnvironment, + resolveEnvironment, + type EnvironmentAddResult, + type EnvironmentRemoveResult +} from '../runtime/environments' + +export const ENVIRONMENT_HANDLERS: Record = { + 'environment add': async ({ flags, json }) => { + const name = getRequiredStringFlag(flags, 'name') + const pairingCode = getRequiredStringFlag(flags, 'pairing-code') + const environment = redactRuntimeEnvironment( + addEnvironmentFromPairingCode(getDefaultUserDataPath(), { + name, + pairingCode + }) + ) + printResult( + localSuccess({ environment }), + json, + (result: EnvironmentAddResult) => + `Saved environment ${result.environment.name} (${result.environment.id}).` + ) + }, + 'environment list': async ({ json }) => { + const environments = listEnvironments(getDefaultUserDataPath()).map(redactRuntimeEnvironment) + printResult(localSuccess({ environments }), json, formatEnvironmentList) + }, + 'environment show': async ({ flags, json }) => { + const selector = getRequiredStringFlag(flags, 'environment') + const environment = redactRuntimeEnvironment( + resolveEnvironment(getDefaultUserDataPath(), selector) + ) + printResult(localSuccess({ environment }), json, ({ environment: value }) => + formatEnvironment(value) + ) + }, + 'environment rm': async ({ flags, json }) => { + const selector = getRequiredStringFlag(flags, 'environment') + const removed = redactRuntimeEnvironment(removeEnvironment(getDefaultUserDataPath(), selector)) + printResult( + localSuccess({ removed }), + json, + (result: EnvironmentRemoveResult) => + `Removed environment ${result.removed.name} (${result.removed.id}).` + ) + } +} + +function getRequiredStringFlag(flags: Map, name: string): string { + const value = flags.get(name) + if (typeof value !== 'string' || value.length === 0) { + throw new RuntimeClientError('invalid_argument', `Missing required --${name}`) + } + return value +} + +function localSuccess(result: TResult): RuntimeRpcSuccess { + return { + id: 'local', + ok: true, + result, + _meta: { + runtimeId: 'local' + } + } +} diff --git a/src/cli/handlers/repo.ts b/src/cli/handlers/repo.ts index a1061ee271b..fc4c9f22809 100644 --- a/src/cli/handlers/repo.ts +++ b/src/cli/handlers/repo.ts @@ -1,16 +1,43 @@ +import { resolve as resolvePath } from 'path' import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format' import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags' +import { RuntimeClientError } from '../runtime-client' + +function isAbsoluteServerPath(value: string): boolean { + return ( + value.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(value) || + value.startsWith('\\\\') || + value.startsWith('//') + ) +} + +function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string { + if (!isRemote) { + return resolvePath(cwd, inputPath) + } + // Why: the local CLI cwd is unrelated to a paired runtime's filesystem. + // Relative remote paths would silently target the wrong machine. + if (!isAbsoluteServerPath(inputPath)) { + throw new RuntimeClientError( + 'invalid_argument', + 'Remote repo add requires --path to be an absolute path on the remote server.' + ) + } + return inputPath +} export const REPO_HANDLERS: Record = { 'repo list': async ({ client, json }) => { const result = await client.call('repo.list') printResult(result, json, formatRepoList) }, - 'repo add': async ({ flags, client, json }) => { + 'repo add': async ({ flags, client, cwd, json }) => { + const repoPath = getRequiredStringFlag(flags, 'path') const result = await client.call<{ repo: Record }>('repo.add', { - path: getRequiredStringFlag(flags, 'path') + path: resolveRepoAddPath(repoPath, cwd, client.isRemote) }) printResult(result, json, formatRepoShow) }, diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index e59ad46e712..831796a488f 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -116,6 +116,12 @@ export const TERMINAL_HANDLERS: Record = { printResult(result, json, formatTerminalRename) }, 'terminal create': async ({ flags, client, cwd, json }) => { + if (client.isRemote && !flags.has('worktree')) { + throw new RuntimeClientError( + 'invalid_argument', + 'Remote terminal create requires --worktree because the client cwd cannot identify a server worktree.' + ) + } const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { worktree: await getBrowserWorktreeSelector(flags, cwd, client), command: getOptionalStringFlag(flags, 'command'), diff --git a/src/cli/help.ts b/src/cli/help.ts index 411ddd98715..b129306953f 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -8,8 +8,15 @@ Usage: orca [options] Startup: open Launch Orca and wait for the runtime to be reachable + serve Start a headless Orca runtime server status Show app/runtime/graph readiness +Environments: + environment add Save a remote Orca runtime from a pairing code + environment list List saved remote Orca runtimes + environment show Show one saved remote Orca runtime + environment rm Remove a saved remote Orca runtime + Repos: repo list List repos registered in Orca repo add Add a project to Orca by filesystem path @@ -138,7 +145,12 @@ Browser Automation: Common Commands: orca open [--json] + orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--json] orca status [--json] + orca environment add --name --pairing-code [--json] + orca environment list [--json] + orca environment show --environment [--json] + orca environment rm --environment [--json] orca worktree list [--repo ] [--limit ] [--json] orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] @@ -178,10 +190,13 @@ Wait Options: Output Options: --json Emit machine-readable JSON instead of human text + --pairing-code Connect to a remote Orca runtime using an orca://pair#... code + --environment Connect using a saved environment id or name --help Show this help message Behavior: Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first. + Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT. Use selectors for discovery and handles for repeated live terminal operations. Browser Workflow: diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 668354a2607..6758745bd0d 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -2,13 +2,47 @@ import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const callMock = vi.fn() +const { + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn() +})) vi.mock('./runtime-client', () => { class RuntimeClient { + readonly isRemote: boolean call = callMock getCliStatus = vi.fn() openOrca = vi.fn() + + constructor( + _userDataPath?: string, + _requestTimeoutMs?: number, + remotePairingCode?: string | null, + environmentSelector?: string | null + ) { + const effectivePairingCode = + remotePairingCode === undefined + ? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING) + : remotePairingCode + const effectiveEnvironment = + environmentSelector === undefined ? process.env.ORCA_ENVIRONMENT : environmentSelector + if (effectivePairingCode && effectiveEnvironment) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --pairing-code or --environment, not both.' + ) + } + this.isRemote = Boolean(effectivePairingCode || effectiveEnvironment) + } } class RuntimeClientError extends Error { @@ -32,10 +66,19 @@ vi.mock('./runtime-client', () => { return { RuntimeClient, RuntimeClientError, - RuntimeRpcFailureError + RuntimeRpcFailureError, + serveOrcaApp: serveOrcaAppMock, + getDefaultUserDataPath: getDefaultUserDataPathMock } }) +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) + import { buildCurrentWorktreeSelector, COMMAND_SPECS, @@ -58,9 +101,36 @@ describe('COMMAND_SPECS collision check', () => { describe('orca cli worktree awareness', () => { const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE const originalUserDataPath = process.env.ORCA_USER_DATA_PATH + const originalPairingCode = process.env.ORCA_PAIRING_CODE + const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING + const originalEnvironment = process.env.ORCA_ENVIRONMENT beforeEach(() => { callMock.mockReset() + serveOrcaAppMock.mockReset() + getDefaultUserDataPathMock.mockClear() + addEnvironmentFromPairingCodeMock.mockReset() + listEnvironmentsMock.mockReset() + addEnvironmentFromPairingCodeMock.mockReturnValue({ + id: 'env-1', + name: 'desk', + createdAt: 100, + updatedAt: 100, + lastUsedAt: null, + runtimeId: null, + endpoints: [ + { + id: 'ws-env-1', + kind: 'websocket', + label: 'WebSocket', + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: 'pk' + } + ], + preferredEndpointId: 'ws-env-1' + }) + listEnvironmentsMock.mockReturnValue([]) }) afterEach(() => { @@ -75,6 +145,21 @@ describe('orca cli worktree awareness', () => { } else { process.env.ORCA_USER_DATA_PATH = originalUserDataPath } + if (originalPairingCode === undefined) { + delete process.env.ORCA_PAIRING_CODE + } else { + process.env.ORCA_PAIRING_CODE = originalPairingCode + } + if (originalRemotePairing === undefined) { + delete process.env.ORCA_REMOTE_PAIRING + } else { + process.env.ORCA_REMOTE_PAIRING = originalRemotePairing + } + if (originalEnvironment === undefined) { + delete process.env.ORCA_ENVIRONMENT + } else { + process.env.ORCA_ENVIRONMENT = originalEnvironment + } }) it('builds the current worktree selector from cwd', () => { @@ -115,6 +200,25 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledTimes(1) }) + it('rejects remote `worktree current` without listing worktrees from client cwd', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['worktree', 'current', '--pairing-code', 'remote-runtime', '--json'], + '/tmp/repo/src' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'current is a local cwd shortcut and cannot be resolved against a remote runtime.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('uses cwd when active is passed to worktree.set', async () => { queueFixtures( callMock, @@ -171,6 +275,192 @@ describe('orca cli worktree awareness', () => { }) }) + it('starts a foreground headless server through `serve`', async () => { + serveOrcaAppMock.mockResolvedValue(0) + process.env.ORCA_ENVIRONMENT = 'stale-env' + + await main( + ['serve', '--json', '--port', '6768', '--pairing-address', '100.64.1.20', '--no-pairing'], + '/tmp/repo' + ) + + expect(serveOrcaAppMock).toHaveBeenCalledWith({ + json: true, + port: '6768', + pairingAddress: '100.64.1.20', + noPairing: true, + mobilePairing: false + }) + }) + + it('starts a foreground headless server with mobile pairing enabled', async () => { + serveOrcaAppMock.mockResolvedValue(0) + + await main( + ['serve', '--pairing-address', '100.64.1.20', '--mobile-pairing', '--json'], + '/tmp/repo' + ) + + expect(serveOrcaAppMock).toHaveBeenCalledWith({ + json: true, + port: null, + pairingAddress: '100.64.1.20', + noPairing: false, + mobilePairing: true + }) + }) + + it('rejects contradictory serve pairing flags', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['serve', '--mobile-pairing', '--no-pairing', '--json'], '/tmp/repo') + + expect(serveOrcaAppMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Use either --mobile-pairing or --no-pairing, not both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects invalid serve ports before launching the app', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['serve', '--port', 'not-a-port', '--json'], '/tmp/repo') + + expect(serveOrcaAppMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Invalid --port value: not-a-port' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => { + process.env.ORCA_ENVIRONMENT = 'stale-env' + listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()]) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['environment', 'list', '--json'], '/tmp/repo') + + expect(listEnvironmentsMock).toHaveBeenCalledWith('/tmp/orca-user-data') + expect(callMock).not.toHaveBeenCalled() + expect(logSpy.mock.calls[0]?.[0]).not.toContain('token') + expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64') + }) + + it('adds saved environments even when ORCA_ENVIRONMENT is set', async () => { + process.env.ORCA_ENVIRONMENT = 'stale-env' + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['environment', 'add', '--name', 'desk', '--pairing-code', 'orca://pair#abc', '--json'], + '/tmp/repo' + ) + + expect(addEnvironmentFromPairingCodeMock).toHaveBeenCalledWith('/tmp/orca-user-data', { + name: 'desk', + pairingCode: 'orca://pair#abc' + }) + expect(callMock).not.toHaveBeenCalled() + expect(logSpy.mock.calls[0]?.[0]).not.toContain('token') + expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64') + }) + + it('resolves repo.add paths against the invoking cli cwd', async () => { + queueFixtures( + callMock, + okFixture('req_repo_add', { + repo: { + id: 'repo-1', + path: path.resolve('/tmp/repo/apps/web'), + displayName: 'web' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['repo', 'add', '--path', './apps/web', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('repo.add', { + path: path.resolve('/tmp/repo/apps/web') + }) + }) + + it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['repo', 'add', '--path', './apps/web', '--pairing-code', 'remote-runtime', '--json'], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Remote repo add requires --path to be an absolute path on the remote server.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('sends remote repo.add absolute paths unchanged', async () => { + queueFixtures( + callMock, + okFixture('req_repo_add', { + repo: { + id: 'repo-1', + path: '/srv/orca/web', + displayName: 'web' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['repo', 'add', '--path', '/srv/orca/web', '--pairing-code', 'remote-runtime', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('repo.add', { + path: '/srv/orca/web' + }) + }) + + it.each(['C:\\repo', 'C:/repo', '\\\\server\\share\\repo', '//server/share/repo'])( + 'sends remote repo.add server absolute path %s unchanged', + async (serverPath) => { + queueFixtures( + callMock, + okFixture('req_repo_add', { + repo: { + id: 'repo-1', + path: serverPath, + displayName: 'web' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['repo', 'add', '--path', serverPath, '--pairing-code', 'remote-runtime', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('repo.add', { + path: serverPath + }) + } + ) + it('opts into setup and activation when worktree.create runs hooks', async () => { queueFixtures( callMock, @@ -352,4 +642,79 @@ describe('orca cli worktree awareness', () => { limit: undefined }) }) + + it('rejects implicit remote terminal create instead of resolving from client cwd', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['terminal', 'create', '--pairing-code', 'remote-runtime', '--json'], + '/tmp/client/repo/src' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Remote terminal create requires --worktree' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('sends explicit remote terminal create worktree selectors unchanged', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_create', { + terminal: { + handle: 'term_1', + worktreeId: 'repo-1::/srv/orca/feature', + title: 'Server terminal' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'terminal', + 'create', + '--worktree', + 'id:repo-1::/srv/orca/feature', + '--pairing-code', + 'remote-runtime', + '--json' + ], + '/tmp/client/repo/src' + ) + + expect(callMock).toHaveBeenCalledWith('terminal.create', { + worktree: 'id:repo-1::/srv/orca/feature', + command: undefined, + title: undefined, + focus: false + }) + }) + + it('does not resolve implicit remote browser targets from client cwd', async () => { + queueFixtures( + callMock, + okFixture('req_tab_current', { + tab: { + browserPageId: 'page-1', + index: 0, + url: 'https://example.com', + title: 'Example', + active: true, + worktreeId: 'repo-1::/srv/orca/feature' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined }) + }) }) diff --git a/src/cli/index.ts b/src/cli/index.ts index ed015d1185d..00a96e9043f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,6 +15,10 @@ import { COMMAND_SPECS } from './specs' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' +function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { + return commandPath[0] === 'environment' || commandPath[0] === 'serve' +} + export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { const parsed = parseArgs(argv) const helpPath = resolveHelpPath(parsed) @@ -40,7 +44,15 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P // lookup so users do not get misleading "Orca is not running" failures for // simple command typos or unsupported flags. validateCommandAndFlags(COMMAND_SPECS, parsed) - const client = new RuntimeClient() + const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) + const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') + const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment') + const client = new RuntimeClient( + undefined, + undefined, + typeof pairingCode === 'string' ? pairingCode : undefined, + typeof environmentSelector === 'string' ? environmentSelector : undefined + ) await dispatch(parsed.commandPath, { flags: parsed.flags, client, diff --git a/src/cli/runtime-client.ts b/src/cli/runtime-client.ts index 480637847eb..f88b116981b 100644 --- a/src/cli/runtime-client.ts +++ b/src/cli/runtime-client.ts @@ -6,6 +6,7 @@ export { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError, + serveOrcaApp, getDefaultUserDataPath, type RuntimeRpcFailure, type RuntimeRpcResponse, diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index ae65cd4f34c..ab082aa16ad 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -1,9 +1,17 @@ -import type { CliStatusResult } from '../../shared/runtime-types' +import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import { launchOrcaApp } from './launch' import { getDefaultUserDataPath, readMetadata } from './metadata' import { getCliStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +import { sendWebSocketRequest } from './websocket-transport' +import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments' +import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../shared/protocol-version' // Why: for `orchestration.check --wait` the caller's method-level // `params.timeoutMs` is the inner waiter budget; we extend the client-side @@ -16,13 +24,27 @@ const LONG_POLL_CLIENT_GRACE_MS = 10_000 export class RuntimeClient { private readonly userDataPath: string private readonly requestTimeoutMs: number + private readonly remotePairing: PairingOffer | null + private readonly environmentSelector: string | null + private remoteCompatChecked = false // Why: browser commands trigger first-time session init (agent-browser connect + // CDP proxy setup) which can take 15-30s. 60s accommodates cold start without // being so large that genuine hangs go unnoticed. - constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000) { + constructor( + userDataPath = getDefaultUserDataPath(), + requestTimeoutMs = 60_000, + remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null, + environmentSelector = process.env.ORCA_ENVIRONMENT ?? null + ) { this.userDataPath = userDataPath this.requestTimeoutMs = requestTimeoutMs + this.environmentSelector = environmentSelector + this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector) + } + + get isRemote(): boolean { + return this.remotePairing !== null } async call( @@ -32,10 +54,30 @@ export class RuntimeClient { timeoutMs?: number } ): Promise> { - const metadata = readMetadata(this.userDataPath) const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params) + if (this.remotePairing) { + if (method !== 'status.get') { + await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs) + } + const response = await sendWebSocketRequest( + this.remotePairing, + method, + params, + effectiveTimeoutMs + ) + if (response.ok === false) { + throw new RuntimeRpcFailureError(response) + } + if (this.environmentSelector) { + markEnvironmentUsed(this.userDataPath, this.environmentSelector, { + runtimeId: response._meta.runtimeId + }) + } + return response + } + const metadata = readMetadata(this.userDataPath) const response = await sendRequest(metadata, method, params, effectiveTimeoutMs) - if (!response.ok) { + if (response.ok === false) { throw new RuntimeRpcFailureError(response) } return response @@ -58,9 +100,69 @@ export class RuntimeClient { } async getCliStatus(): Promise> { + if (this.remotePairing) { + const response = await this.call('status.get') + this.assertRemoteRuntimeStatusCompatible(response.result) + this.remoteCompatChecked = true + const graphState = response.result.graphStatus + return { + id: response.id, + ok: true, + result: { + app: { + running: true, + pid: null + }, + runtime: { + state: graphState === 'ready' ? 'ready' : 'graph_not_ready', + reachable: true, + runtimeId: response.result.runtimeId + }, + graph: { + state: graphState + } + }, + _meta: response._meta + } + } return getCliStatus(this.userDataPath) } + private async ensureRemoteRuntimeCompatible(timeoutMs: number): Promise { + if (!this.remotePairing || this.remoteCompatChecked) { + return + } + const response = await sendWebSocketRequest( + this.remotePairing, + 'status.get', + undefined, + timeoutMs + ) + if (response.ok === false) { + throw new RuntimeRpcFailureError(response) + } + this.assertRemoteRuntimeStatusCompatible(response.result) + this.remoteCompatChecked = true + if (this.environmentSelector) { + markEnvironmentUsed(this.userDataPath, this.environmentSelector, { + runtimeId: response._meta.runtimeId + }) + } + } + + private assertRemoteRuntimeStatusCompatible(status: RuntimeStatus): void { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) + if (verdict.kind === 'blocked') { + throw new RuntimeClientError('incompatible_runtime', describeRuntimeCompatBlock(verdict)) + } + } + async openOrca(timeoutMs = 15_000): Promise> { const initial = await this.getCliStatus() if (initial.result.runtime.reachable) { @@ -84,6 +186,33 @@ export class RuntimeClient { } } +function resolveRemotePairing( + userDataPath: string, + pairingCode: string | null, + environmentSelector: string | null +): PairingOffer | null { + if (pairingCode && environmentSelector) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --pairing-code or --environment, not both.' + ) + } + if (environmentSelector) { + return resolveEnvironmentPairingOffer(userDataPath, environmentSelector) + } + if (!pairingCode) { + return null + } + const pairing = parsePairingCode(pairingCode) + if (!pairing) { + throw new RuntimeClientError( + 'invalid_argument', + 'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.' + ) + } + return pairing +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/src/cli/runtime/envelope-schema.ts b/src/cli/runtime/envelope-schema.ts index f674aab11af..3447c007769 100644 --- a/src/cli/runtime/envelope-schema.ts +++ b/src/cli/runtime/envelope-schema.ts @@ -1,62 +1,5 @@ -// Why: the Orca runtime is a separate process and may drift in version from -// the CLI (older CLI talking to newer app, or vice versa during dev HMR). A -// Zod schema at the decode boundary means a malformed frame surfaces as a -// single legible error instead of a silent mis-typed access downstream. -// -// The envelope shape mirrors src/main/runtime/rpc/core.ts. `result` is left -// unknown here — method-level types are checked by the caller via generics — -// so only the frame is validated, not the payload. -import { z } from 'zod' - -const MetaSuccess = z.object({ - runtimeId: z.string() -}) - -const MetaFailure = z - .object({ - runtimeId: z.union([z.string(), z.null()]) - }) - .optional() - -const Success = z.object({ - id: z.string(), - ok: z.literal(true), - result: z.unknown(), - _meta: MetaSuccess -}) - -const Failure = z.object({ - id: z.string(), - ok: z.literal(false), - error: z.object({ - code: z.string(), - message: z.string(), - data: z.unknown().optional() - }), - _meta: MetaFailure -}) - -// Why: transport-layer keepalive frame (server→client only). Not a terminal -// frame — the client reads past it and keeps waiting for the real -// success/failure. `id` and `_meta` are deliberately absent: keepalives carry -// no method-level semantics and aren't tied to a particular request (one -// connection handles one request today). See design doc §3.1. -const Keepalive = z.object({ - _keepalive: z.literal(true) -}) - -// Why: switched from z.discriminatedUnion('ok', …) to z.union because -// keepalives have no `ok` field. Client code must branch on -// `'_keepalive' in frame` before treating the frame as Success/Failure. -export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive]) - -export type RuntimeRpcKeepaliveFrame = z.infer - -export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame { - return ( - typeof frame === 'object' && - frame !== null && - '_keepalive' in frame && - (frame as { _keepalive: unknown })._keepalive === true - ) -} +export { + isKeepaliveFrame, + RuntimeRpcEnvelopeSchema, + type RuntimeRpcKeepaliveFrame +} from '../../shared/runtime-rpc-envelope' diff --git a/src/cli/runtime/environments.test.ts b/src/cli/runtime/environments.test.ts new file mode 100644 index 00000000000..8c27060cdba --- /dev/null +++ b/src/cli/runtime/environments.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, statSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import { + addEnvironmentFromPairingCode, + getEnvironmentStorePath, + listEnvironments, + removeEnvironment, + resolveEnvironmentPairingOffer +} from './environments' + +function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { + return encodePairingOffer({ + v: 2, + endpoint, + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) +} + +describe('CLI runtime environments', () => { + it('saves, resolves, and removes a paired environment', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-')) + const saved = addEnvironmentFromPairingCode(userDataPath, { + name: 'workstation', + pairingCode: pairingCode(), + now: 100 + }) + + expect(listEnvironments(userDataPath)).toHaveLength(1) + expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation')).toMatchObject({ + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token' + }) + expect(resolveEnvironmentPairingOffer(userDataPath, saved.id)).toMatchObject({ + endpoint: 'ws://127.0.0.1:6768' + }) + expect((statSync(getEnvironmentStorePath(userDataPath)).mode & 0o777).toString(8)).toBe('600') + + const removed = removeEnvironment(userDataPath, 'workstation') + expect(removed.id).toBe(saved.id) + expect(listEnvironments(userDataPath)).toEqual([]) + }) + + it('rejects an environment with the same name', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-')) + const first = addEnvironmentFromPairingCode(userDataPath, { + name: 'workstation', + pairingCode: pairingCode('ws://127.0.0.1:1111'), + now: 100 + }) + + expect(() => + addEnvironmentFromPairingCode(userDataPath, { + name: 'workstation', + pairingCode: pairingCode('ws://127.0.0.1:2222'), + now: 200 + }) + ).toThrow('A server named "workstation" already exists.') + expect(listEnvironments(userDataPath)).toHaveLength(1) + expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation').endpoint).toBe( + 'ws://127.0.0.1:1111' + ) + expect(listEnvironments(userDataPath)[0]?.id).toBe(first.id) + }) +}) diff --git a/src/cli/runtime/environments.ts b/src/cli/runtime/environments.ts new file mode 100644 index 00000000000..3009b11c9cb --- /dev/null +++ b/src/cli/runtime/environments.ts @@ -0,0 +1,81 @@ +import { + addEnvironmentFromPairingCode as addEnvironmentFromPairingCodeInStore, + getEnvironmentStorePath, + listEnvironments, + markEnvironmentUsed as markEnvironmentUsedInStore, + removeEnvironment as removeEnvironmentFromStore, + resolveEnvironment as resolveEnvironmentFromStore, + resolveEnvironmentPairingOffer as resolveEnvironmentPairingOfferFromStore, + RuntimeEnvironmentStoreError, + type RuntimeEnvironmentStoreErrorCode +} from '../../shared/runtime-environment-store' +import type { + KnownRuntimeEnvironment, + PublicKnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import type { PairingOffer } from '../../shared/pairing' +import { RuntimeClientError } from './types' + +export type EnvironmentAddResult = { + environment: PublicKnownRuntimeEnvironment +} + +export type EnvironmentListResult = { + environments: PublicKnownRuntimeEnvironment[] +} + +export type EnvironmentRemoveResult = { + removed: PublicKnownRuntimeEnvironment +} + +export { getEnvironmentStorePath, listEnvironments } + +export function addEnvironmentFromPairingCode( + userDataPath: string, + args: { name: string; pairingCode: string; now?: number } +): KnownRuntimeEnvironment { + return translateStoreError(() => addEnvironmentFromPairingCodeInStore(userDataPath, args)) +} + +export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment { + return translateStoreError(() => removeEnvironmentFromStore(userDataPath, selector)) +} + +export function resolveEnvironment( + userDataPath: string, + selector: string +): KnownRuntimeEnvironment { + return translateStoreError(() => resolveEnvironmentFromStore(userDataPath, selector)) +} + +export function resolveEnvironmentPairingOffer( + userDataPath: string, + selector: string +): PairingOffer { + return translateStoreError(() => resolveEnvironmentPairingOfferFromStore(userDataPath, selector)) +} + +export function markEnvironmentUsed( + userDataPath: string, + selector: string, + args: { runtimeId?: string | null; now?: number } = {} +): void { + translateStoreError(() => markEnvironmentUsedInStore(userDataPath, selector, args)) +} + +function translateStoreError(fn: () => TResult): TResult { + try { + return fn() + } catch (error) { + if (error instanceof RuntimeEnvironmentStoreError) { + throw new RuntimeClientError(toRuntimeClientErrorCode(error.code), error.message) + } + throw error + } +} + +function toRuntimeClientErrorCode( + code: RuntimeEnvironmentStoreErrorCode +): 'invalid_argument' | 'runtime_error' { + return code +} diff --git a/src/cli/runtime/index.ts b/src/cli/runtime/index.ts index b061c042b21..81d9fdf16c6 100644 --- a/src/cli/runtime/index.ts +++ b/src/cli/runtime/index.ts @@ -1,4 +1,5 @@ export { RuntimeClient } from './client' +export { serveOrcaApp } from './launch' export { getDefaultUserDataPath } from './metadata' export { RuntimeClientError, diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts new file mode 100644 index 00000000000..807df9dc721 --- /dev/null +++ b/src/cli/runtime/launch.test.ts @@ -0,0 +1,88 @@ +import { resolve } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +import { serveOrcaApp } from './launch' + +describe('serveOrcaApp', () => { + beforeEach(() => { + spawnMock.mockReset() + process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca' + }) + + afterEach(() => { + delete process.env.ORCA_APP_EXECUTABLE + }) + + it('pins the Electron child cwd to the app root instead of the caller cwd', async () => { + const child = { + kill: vi.fn(), + once: vi.fn( + (event: string, handler: (code: number | null, signal: string | null) => void) => { + if (event === 'exit') { + queueMicrotask(() => handler(0, null)) + } + return child + } + ) + } + spawnMock.mockReturnValue(child) + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + + expect(spawnMock).toHaveBeenCalledWith( + '/Applications/Orca.app/Contents/MacOS/Orca', + ['--serve', '--serve-json'], + expect.objectContaining({ + cwd: resolve(__dirname, '../../..') + }) + ) + }) + + it('passes mobile pairing through to the foreground server child', async () => { + const child = { + kill: vi.fn(), + once: vi.fn( + (event: string, handler: (code: number | null, signal: string | null) => void) => { + if (event === 'exit') { + queueMicrotask(() => handler(0, null)) + } + return child + } + ) + } + spawnMock.mockReturnValue(child) + + await expect( + serveOrcaApp({ + json: true, + port: '6768', + pairingAddress: '100.64.1.20', + mobilePairing: true + }) + ).resolves.toBe(0) + + expect(spawnMock).toHaveBeenCalledWith( + '/Applications/Orca.app/Contents/MacOS/Orca', + [ + '--serve', + '--serve-json', + '--serve-port', + '6768', + '--serve-pairing-address', + '100.64.1.20', + '--serve-mobile-pairing' + ], + expect.objectContaining({ + cwd: resolve(__dirname, '../../..') + }) + ) + }) +}) diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index b86cfb9b4d1..71de311177b 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,5 +1,5 @@ import { spawn as spawnProcess } from 'child_process' -import { dirname } from 'path' +import { dirname, resolve } from 'path' import { RuntimeClientError } from './types' export function launchOrcaApp(): void { @@ -53,6 +53,93 @@ export function launchOrcaApp(): void { ) } +export function serveOrcaApp( + args: { + json?: boolean + port?: string | null + pairingAddress?: string | null + noPairing?: boolean + mobilePairing?: boolean + } = {} +): Promise { + const executable = resolveForegroundOrcaExecutable() + const childArgs = ['--serve'] + if (args.json) { + childArgs.push('--serve-json') + } + if (args.port) { + childArgs.push('--serve-port', args.port) + } + if (args.pairingAddress) { + childArgs.push('--serve-pairing-address', args.pairingAddress) + } + if (args.noPairing) { + childArgs.push('--serve-no-pairing') + } + if (args.mobilePairing) { + childArgs.push('--serve-mobile-pairing') + } + + const child = spawnProcess(executable, childArgs, { + cwd: resolveAppRoot(), + stdio: 'inherit', + env: stripElectronRunAsNode(process.env) + }) + + return new Promise((resolve, reject) => { + let forceKillTimer: ReturnType | null = null + const forwardSignal = (signal: NodeJS.Signals): void => { + child.kill(signal) + forceKillTimer ??= setTimeout(() => { + child.kill('SIGKILL') + }, 5000) + } + const cleanup = (): void => { + process.off('SIGINT', forwardSignal) + process.off('SIGTERM', forwardSignal) + if (forceKillTimer) { + clearTimeout(forceKillTimer) + forceKillTimer = null + } + } + process.on('SIGINT', forwardSignal) + process.on('SIGTERM', forwardSignal) + child.once('error', (error) => { + cleanup() + reject(error) + }) + child.once('exit', (code, signal) => { + cleanup() + if (typeof code === 'number') { + resolve(code) + return + } + reject(new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${signal}`)) + }) + }) +} + +function resolveAppRoot(): string { + // Why: dev-mode resource resolution in the Electron child may consult + // process.cwd(). Pin it to the app root so `orca serve` behaves the same + // regardless of the shell directory it was launched from. + return resolve(__dirname, '../../..') +} + +function resolveForegroundOrcaExecutable(): string { + const overrideExecutable = process.env.ORCA_APP_EXECUTABLE + if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) { + return overrideExecutable + } + if (process.env.ELECTRON_RUN_AS_NODE === '1') { + return process.execPath + } + throw new RuntimeClientError( + 'runtime_serve_failed', + 'Could not determine how to start Orca server. Set ORCA_APP_EXECUTABLE to the Orca executable.' + ) +} + function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next = { ...env } delete next.ELECTRON_RUN_AS_NODE diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 3a0f445a40f..bbf5e7e272f 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -29,7 +29,7 @@ export async function getCliStatus( try { const response = await sendRequest(metadata, 'status.get', undefined, 1000) - if (!response.ok) { + if (response.ok === false) { throw new RuntimeRpcFailureError(response) } const graphState = response.result.graphStatus diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index ca8ca746274..ee80e484a6d 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -49,10 +49,10 @@ export async function sendRequest( settled = true clearTimeout(timeout) socket.end() - if (result.ok) { - resolve(result.response) - } else { + if (result.ok === false) { reject(result.error) + } else { + resolve(result.response) } } diff --git a/src/cli/runtime/types.ts b/src/cli/runtime/types.ts index e427c87b5de..220d3e3aba5 100644 --- a/src/cli/runtime/types.ts +++ b/src/cli/runtime/types.ts @@ -1,31 +1,10 @@ -// Why: the RPC envelope shape is the contract the CLI shares with the main -// runtime. Keeping the types and error classes in one leaf module lets every -// other runtime module depend on them without pulling in transport or launch -// code. +import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope' -export type RuntimeRpcSuccess = { - id: string - ok: true - result: TResult - _meta: { - runtimeId: string - } -} - -export type RuntimeRpcFailure = { - id: string - ok: false - error: { - code: string - message: string - data?: unknown - } - _meta?: { - runtimeId: string | null - } -} - -export type RuntimeRpcResponse = RuntimeRpcSuccess | RuntimeRpcFailure +export type { + RuntimeRpcFailure, + RuntimeRpcResponse, + RuntimeRpcSuccess +} from '../../shared/runtime-rpc-envelope' export class RuntimeClientError extends Error { readonly code: string diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts new file mode 100644 index 00000000000..38ccf06f956 --- /dev/null +++ b/src/cli/runtime/websocket-transport.test.ts @@ -0,0 +1,237 @@ +import { createServer, type Server } from 'http' +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { WebSocketServer } from 'ws' +import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' +import { + decrypt, + deriveSharedKey, + encrypt, + generateKeyPair, + publicKeyToBase64 +} from '../../shared/e2ee-crypto' +import { RuntimeClient } from './client' +import { addEnvironmentFromPairingCode } from './environments' +import { RuntimeClientError } from './types' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../shared/protocol-version' + +type TestRuntime = { + endpoint: string + publicKeyB64: string + deviceToken: string + close: () => Promise +} + +describe('CLI remote WebSocket transport', () => { + const servers: TestRuntime[] = [] + + afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())) + }) + + it('calls a remote runtime through a mobile pairing offer', async () => { + const runtime = await startTestRuntime('runtime-ws-1') + servers.push(runtime) + + const pairingUrl = encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + const client = new RuntimeClient('/tmp/unused', 5_000, pairingUrl) + const response = await client.call<{ runtimeId: string }>('status.get') + + expect(response.ok).toBe(true) + expect(response.result.runtimeId).toBe('runtime-ws-1') + }) + + it('rejects malformed remote pairing codes before local runtime lookup', () => { + expect(() => new RuntimeClient('/tmp/unused', 5_000, 'not-a-pairing-code')).toThrow( + RuntimeClientError + ) + }) + + it('accepts a bare pairing payload as well as the orca URL wrapper', async () => { + const runtime = await startTestRuntime('runtime-ws-2') + servers.push(runtime) + const offer: PairingOffer = { + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + } + const barePayload = encodePairingOffer(offer).split('#')[1]! + + const client = new RuntimeClient('/tmp/unused', 5_000, barePayload) + const status = await client.getCliStatus() + + expect(status.result.runtime.reachable).toBe(true) + expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') + }) + + it('connects through a saved environment selector', async () => { + const runtime = await startTestRuntime('runtime-env-1') + servers.push(runtime) + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-env-')) + addEnvironmentFromPairingCode(userDataPath, { + name: 'remote-dev', + pairingCode: encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + }) + + const client = new RuntimeClient(userDataPath, 5_000, null, 'remote-dev') + const status = await client.getCliStatus() + + expect(status.result.runtime.reachable).toBe(true) + expect(status.result.runtime.runtimeId).toBe('runtime-env-1') + }) + + it('blocks remote RPCs when the server protocol is too old', async () => { + const runtime = await startTestRuntime('runtime-old', { runtimeProtocolVersion: 1 }) + servers.push(runtime) + + const client = new RuntimeClient( + '/tmp/unused', + 5_000, + encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + ) + + await expect(client.call('repo.list')).rejects.toMatchObject({ + code: 'incompatible_runtime', + message: expect.stringContaining('server is too old') + }) + }) +}) + +async function startTestRuntime( + runtimeId: string, + statusOverrides: { + runtimeProtocolVersion?: number + minCompatibleRuntimeClientVersion?: number + } = {} +): Promise { + const serverKeyPair = generateKeyPair() + const deviceToken = `token-${runtimeId}` + const httpServer = createServer() + const wss = new WebSocketServer({ server: httpServer }) + + wss.on('connection', (ws) => { + let sharedKey: Uint8Array | null = null + let authenticated = false + + ws.on('message', (data) => { + const frame = data.toString() + if (!sharedKey) { + const hello = JSON.parse(frame) as { type?: string; publicKeyB64?: string } + const clientPublicKey = Buffer.from(hello.publicKeyB64 ?? '', 'base64') + sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) + ws.send(JSON.stringify({ type: 'e2ee_ready' })) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (!plaintext) { + ws.close(4003, 'decrypt failed') + return + } + if (!authenticated) { + const auth = JSON.parse(plaintext) as { type?: string; deviceToken?: string } + if (auth.type !== 'e2ee_auth' || auth.deviceToken !== deviceToken) { + ws.send(encrypt(JSON.stringify({ type: 'e2ee_error' }), sharedKey)) + ws.close(4001, 'auth failed') + return + } + authenticated = true + ws.send(encrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey)) + return + } + + const request = JSON.parse(plaintext) as { id: string; method: string } + const response = + request.method === 'status.get' + ? { + id: request.id, + ok: true, + result: { + runtimeId, + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: + statusOverrides.runtimeProtocolVersion ?? RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: + statusOverrides.minCompatibleRuntimeClientVersion ?? + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId } + } + : { + id: request.id, + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId } + } + ws.send(encrypt(JSON.stringify(response), sharedKey)) + }) + }) + + await listen(httpServer) + const address = httpServer.address() + if (!address || typeof address === 'string') { + throw new Error('Expected TCP test server') + } + + return { + endpoint: `ws://127.0.0.1:${address.port}`, + publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey), + deviceToken, + close: async () => { + await new Promise((resolve) => { + wss.close(() => resolve()) + for (const client of wss.clients) { + client.close() + } + }) + await closeHttpServer(httpServer) + } + } +} + +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) +} + +async function closeHttpServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts new file mode 100644 index 00000000000..ac5fc677919 --- /dev/null +++ b/src/cli/runtime/websocket-transport.ts @@ -0,0 +1,22 @@ +import type { PairingOffer } from '../../shared/pairing' +import { + RemoteRuntimeClientError, + sendRemoteRuntimeRequest +} from '../../shared/remote-runtime-client' +import { RuntimeClientError, type RuntimeRpcResponse } from './types' + +export async function sendWebSocketRequest( + pairing: PairingOffer, + method: string, + params: unknown, + timeoutMs: number +): Promise> { + try { + return await sendRemoteRuntimeRequest(pairing, method, params, timeoutMs) + } catch (error) { + if (error instanceof RemoteRuntimeClientError) { + throw new RuntimeClientError(error.code, error.message) + } + throw error + } +} diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 14420cebb69..c56d4f152e0 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -1,5 +1,6 @@ import { isAbsolute, relative, resolve as resolvePath } from 'path' import type { ComputerAppQuery, RuntimeWorktreeListResult } from '../shared/runtime-types' +import { isPathInsideOrEqual } from '../shared/cross-platform-path' import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' import { getOptionalStringFlag, getRequiredStringFlag } from './flags' @@ -26,7 +27,22 @@ export function normalizeWorktreeSelector(selector: string, cwd: string): string return selector } +function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient): void { + if (!client.isRemote) { + return + } + // Why: a paired CLI's cwd belongs to the client machine, not the runtime + // server, so cwd-derived worktree selectors are only valid locally. + throw new RuntimeClientError( + 'invalid_argument', + `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:, branch:, issue:, or path:.` + ) +} + function isWithinPath(parentPath: string, childPath: string): boolean { + if (isPathInsideOrEqual(parentPath, childPath)) { + return true + } const relativePath = relative(parentPath, childPath) return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) } @@ -35,6 +51,8 @@ export async function resolveCurrentWorktreeSelector( cwd: string, client: RuntimeClient ): Promise { + assertLocalCwdWorktreeSelector('current', client) + const currentPath = resolvePath(cwd) const worktrees = await client.call('worktree.list', { limit: 10_000 @@ -68,6 +86,7 @@ export async function getOptionalWorktreeSelector( return undefined } if (value === 'active' || value === 'current') { + assertLocalCwdWorktreeSelector(value, client) return await resolveCurrentWorktreeSelector(cwd, client) } return normalizeWorktreeSelector(value, cwd) @@ -81,13 +100,14 @@ export async function getRequiredWorktreeSelector( ): Promise { const value = getRequiredStringFlag(flags, name) if (value === 'active' || value === 'current') { + assertLocalCwdWorktreeSelector(value, client) return await resolveCurrentWorktreeSelector(cwd, client) } return normalizeWorktreeSelector(value, cwd) } -// Why: browser commands default to the current worktree (auto-resolve from cwd). -// --worktree all bypasses filtering. Omitting --worktree auto-resolves. +// Why: local browser commands default to the current worktree by auto-resolving +// from cwd. Remote commands omit worktree so the runtime uses server-side focus. export async function getBrowserWorktreeSelector( flags: Map, cwd: string, @@ -99,10 +119,14 @@ export async function getBrowserWorktreeSelector( } if (value) { if (value === 'active' || value === 'current') { + assertLocalCwdWorktreeSelector(value, client) return await resolveCurrentWorktreeSelector(cwd, client) } return normalizeWorktreeSelector(value, cwd) } + if (client.isRemote) { + return undefined + } // Default: auto-resolve from cwd try { return await resolveCurrentWorktreeSelector(cwd, client) @@ -146,6 +170,7 @@ export async function getBrowserCommandTarget( return { page } } if (explicitWorktree === 'active' || explicitWorktree === 'current') { + assertLocalCwdWorktreeSelector(explicitWorktree, client) return { page, worktree: await resolveCurrentWorktreeSelector(cwd, client) diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index b85424e8ac0..f8f6d8ffb00 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -9,6 +9,24 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...GLOBAL_FLAGS], examples: ['orca open', 'orca open --json'] }, + { + path: ['serve'], + summary: 'Start an Orca runtime server without opening a desktop window', + usage: + 'orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'port', 'pairing-address', 'mobile-pairing', 'no-pairing'], + notes: [ + 'Runs in the foreground and prints the runtime endpoint. Stop it with Ctrl+C.', + 'Use --pairing-address when clients should connect through a LAN, Tailscale, SSH-forward, or public tunnel address.', + 'Use --mobile-pairing to print a mobile-scoped pairing QR/link instead of the default runtime-environment pairing link.' + ], + examples: [ + 'orca serve', + 'orca serve --json', + 'orca serve --port 6768 --pairing-address 100.64.1.20', + 'orca serve --pairing-address 100.64.1.20 --mobile-pairing' + ] + }, { path: ['status'], summary: 'Show app/runtime/graph readiness', diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts new file mode 100644 index 00000000000..6bfe1940b65 --- /dev/null +++ b/src/cli/specs/environment.ts @@ -0,0 +1,30 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['environment', 'add'], + summary: 'Save a remote Orca runtime environment from a pairing code', + usage: 'orca environment add --name --pairing-code [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'name'], + examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...'] + }, + { + path: ['environment', 'list'], + summary: 'List saved Orca runtime environments', + usage: 'orca environment list [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['environment', 'show'], + summary: 'Show one saved Orca runtime environment', + usage: 'orca environment show --environment [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['environment', 'rm'], + summary: 'Remove one saved Orca runtime environment', + usage: 'orca environment rm --environment [--json]', + allowedFlags: [...GLOBAL_FLAGS] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index 0828ef9b557..ebcbb022fcd 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -4,6 +4,7 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { CORE_COMMAND_SPECS } from './core' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' +import { ENVIRONMENT_COMMAND_SPECS } from './environment' import { NOTE_COMMAND_SPECS } from './note' export const COMMAND_SPECS: CommandSpec[] = [ @@ -12,5 +13,6 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...BROWSER_ADVANCED_COMMAND_SPECS, ...ORCHESTRATION_COMMAND_SPECS, ...COMPUTER_COMMAND_SPECS, + ...ENVIRONMENT_COMMAND_SPECS, ...NOTE_COMMAND_SPECS ] diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts new file mode 100644 index 00000000000..615f382cf18 --- /dev/null +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const builderConfig = require('../../../config/electron-builder.config.cjs') as { + asarUnpack?: string[] +} + +describe('packaged CLI assets', () => { + it('unpacks runtime dependencies used before Electron asar integration is available', () => { + expect(builderConfig.asarUnpack).toEqual( + expect.arrayContaining([ + 'node_modules/ws/**', + 'node_modules/tweetnacl/**', + 'node_modules/zod/**' + ]) + ) + }) +}) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index c9fc0ecc357..3a815558c18 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -220,10 +220,14 @@ describe('getDiff', () => { const result = await getDiff('/repo', 'src/file.ts', false) - expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith(2, ['show', 'HEAD:src/file.ts'], { - cwd: '/repo', - maxBuffer: 10 * 1024 * 1024 - }) + expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith( + 2, + ['show', '--end-of-options', 'HEAD:src/file.ts'], + { + cwd: '/repo', + maxBuffer: 10 * 1024 * 1024 + } + ) expect(result.originalContent).toBe('head-content\n') expect(result.modifiedContent).toBe('working-tree-content') }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 0934e6d1013..b24535d4454 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -551,7 +551,7 @@ async function resolveCompareRef(worktreePath: string): Promise { } async function resolveRefOid(worktreePath: string, ref: string): Promise { - const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], { + const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', '--end-of-options', ref], { cwd: worktreePath }) return stdout.trim() @@ -613,10 +613,13 @@ async function readGitBlobAtOidPath( filePath: string ): Promise { try { - const { stdout } = await gitExecFileAsyncBuffer(['show', `${oid}:${filePath}`], { - cwd: worktreePath, - maxBuffer: MAX_GIT_SHOW_BYTES - }) + const { stdout } = await gitExecFileAsyncBuffer( + ['show', '--end-of-options', `${oid}:${filePath}`], + { + cwd: worktreePath, + maxBuffer: MAX_GIT_SHOW_BYTES + } + ) return { ...bufferToBlob(stdout, filePath), exists: true } } catch { diff --git a/src/main/index.ts b/src/main/index.ts index 47de211cc30..19c20b07e10 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { grantDirAcl } from './win32-utils' import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' +import * as QRCode from 'qrcode' import devIcon from '../../resources/icon-dev.png?asset' import { Store, initDataPath } from './persistence' import { StatsCollector, initStatsPath } from './stats/collector' @@ -51,7 +52,12 @@ import { codexHookService } from './codex/hook-service' import { geminiHookService } from './gemini/hook-service' import { cursorHookService } from './cursor/hook-service' import { droidHookService } from './droid/hook-service' -import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvider } from './ipc/pty' +import { + getPtyIdForPaneKey, + registerPaneKeyTeardownListener, + getLocalPtyProvider, + registerHeadlessPtyRuntime +} from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { browserManager } from './browser/browser-manager' import { setUnreadDockBadgeCount } from './dock/unread-badge' @@ -79,6 +85,7 @@ let disposeFeatureWallFirstAgentTour: (() => void) | null = null let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null +const isServeMode = process.argv.includes('--serve') installUncaughtPipeErrorGuard() // Why: propagate the Orca app version into `process.env` so PTY-env @@ -141,7 +148,8 @@ function focusExistingWindow(): void { // agent work, so that routing ambiguity is acceptable. Packaged Orca keeps // the lock to protect against the corruption documented in PR #1326 / // issue #1312. -const hasSingleInstanceLock = is.dev ? true : acquireSingleInstanceLock(app, focusExistingWindow) +const hasSingleInstanceLock = + is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow) if (!hasSingleInstanceLock) { if (is.dev) { // Why: packaged runs have no attached console, but dev runs do. Emit a @@ -160,8 +168,12 @@ if (!hasSingleInstanceLock) { // below happen — those handlers only fire after whenReady, which app.quit() // prevents from ever dispatching. if (hasSingleInstanceLock) { - installDevParentDisconnectQuit(is.dev) - installDevParentWatchdog(is.dev) + // Why: dev parent shutdown coupling is only for electron-vite desktop runs. + // `orca serve` may be launched through a CLI shim or background shell whose + // parent lifetime is not the intended server lifetime. + const shouldCoupleToDevParent = is.dev && !isServeMode + installDevParentDisconnectQuit(shouldCoupleToDevParent) + installDevParentWatchdog(shouldCoupleToDevParent) // Why: must run after configureDevUserDataPath (which redirects userData to // orca-dev in dev mode) but before app.setName('Orca') inside whenReady // (which would change the resolved path on case-sensitive filesystems). @@ -392,6 +404,108 @@ const syntheticTitleSpinnerByPaneKey = new Map< { timer: ReturnType; frame: number; profile: SyntheticTitleProfile } >() +type ServeOptions = { + json: boolean + wsPort?: number + pairingAddress: string | null + noPairing: boolean + mobilePairing: boolean +} + +function getServeOptions(argv = process.argv): ServeOptions { + const valueAfter = (flag: string): string | null => { + const index = argv.indexOf(flag) + if (index === -1) { + return null + } + const value = argv[index + 1] + return value && !value.startsWith('--') ? value : null + } + const rawPort = valueAfter('--serve-port') + let wsPort: number | undefined + if (rawPort) { + const parsedPort = Number(rawPort) + if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { + throw new Error(`Invalid --serve-port value: ${rawPort}`) + } + wsPort = parsedPort + } + return { + json: argv.includes('--serve-json'), + ...(wsPort !== undefined ? { wsPort } : {}), + pairingAddress: valueAfter('--serve-pairing-address'), + noPairing: argv.includes('--serve-no-pairing'), + mobilePairing: argv.includes('--serve-mobile-pairing') + } +} + +async function renderTerminalPairingQr(pairingUrl: string): Promise { + try { + return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) + } catch { + try { + return await QRCode.toString(pairingUrl, { type: 'utf8' }) + } catch { + return null + } + } +} + +async function printServeReady(options: ServeOptions): Promise { + if (!runtime || !runtimeRpc) { + throw new Error('Runtime server must be initialized before printing serve readiness') + } + const endpoint = runtimeRpc.getWebSocketEndpoint() + const pairing = options.noPairing + ? ({ available: false } as const) + : runtimeRpc.createPairingOffer({ + address: options.pairingAddress, + name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, + scope: options.mobilePairing ? 'mobile' : 'runtime' + }) + const pairingQr = + pairing.available && options.mobilePairing + ? await renderTerminalPairingQr(pairing.pairingUrl) + : null + if (options.json) { + console.log( + JSON.stringify({ + type: 'orca_server_ready', + runtimeId: runtime.getRuntimeId(), + endpoint, + pairing: pairing.available + ? { + url: pairing.pairingUrl, + endpoint: pairing.endpoint, + deviceId: pairing.deviceId, + scope: options.mobilePairing ? 'mobile' : 'runtime', + qr: pairingQr + } + : null + }) + ) + return + } + console.log(`Orca server ready: ${endpoint ?? 'websocket unavailable'}`) + if (pairing.available) { + if (options.mobilePairing && pairingQr) { + console.log(`Mobile pairing QR:\n${pairingQr}`) + } + console.log(`Pairing URL: ${pairing.pairingUrl}`) + } +} + +function installServeSignalHandlers(): void { + const quit = (): void => { + // Why: foreground `orca serve` is controlled by the parent CLI/terminal, + // so POSIX termination signals should follow Electron's normal quit path + // and flush runtime metadata, daemon checkpoints, and telemetry. + app.quit() + } + process.once('SIGINT', quit) + process.once('SIGTERM', quit) +} + // Why: on PTY teardown the paneKey→ptyId mapping is dropped, so the spinner // interval would keep firing but sendSyntheticTitle would no-op forever. // Stop the interval explicitly so the process doesn't carry a timer per dead @@ -622,38 +736,70 @@ app.whenReady().then(async () => { // ws://127.0.0.1:6769 is stable; a second dev instance still falls back via // ws-transport's EADDRINUSE handler. const devWsPort = is.dev && !isE2E ? 6769 : undefined + let serveOptions: ServeOptions | null = null + try { + serveOptions = isServeMode ? getServeOptions() : null + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + app.exit(1) + return + } runtimeRpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: app.getPath('userData'), enableWebSocket: true, ...(isE2E ? { wsPort: 0 } : {}), - ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}) + ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), + ...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}) }) registerMobileHandlers(runtimeRpc) - await startFirstWindowStartupServices({ - // Why: the persistent-terminal daemon is always started. If it fails, the - // LocalPtyProvider remains as the implicit fallback — terminals work, just - // without cross-restart persistence. - startDaemonPtyProvider: () => initDaemonPtyProvider(), - // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, - // so the hook server must start before restored terminals can mount. - startAgentHookServer: () => - agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - // Why: hooks source this endpoint file at invocation time, so old PTY - // env still reaches the current Orca process after an app restart. - userDataPath: app.getPath('userData') - }), - onDaemonError: (error) => { - console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) - }, - onAgentHookServerError: (error) => { - // Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar - // enrichment only. Orca must still boot if the loopback receiver fails. - console.error('[agent-hooks] Failed to start local hook server:', error) - } - }) + if (!isServeMode) { + await startFirstWindowStartupServices({ + // Why: the persistent-terminal daemon is desktop-only. Headless + // `orca serve` registers its PTY runtime below and must not spawn the + // desktop daemon or hook loopback listener. + startDaemonPtyProvider: () => initDaemonPtyProvider(), + // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, + // so the hook server must start before restored terminals can mount. + startAgentHookServer: () => + agentHookServer.start({ + env: app.isPackaged ? 'production' : 'development', + // Why: hooks source this endpoint file at invocation time, so old PTY + // env still reaches the current Orca process after an app restart. + userDataPath: app.getPath('userData') + }), + onDaemonError: (error) => { + console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) + }, + onAgentHookServerError: (error) => { + // Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar + // enrichment only. Orca must still boot if the loopback receiver fails. + console.error('[agent-hooks] Failed to start local hook server:', error) + } + }) + } + + if (serveOptions) { + registerHeadlessPtyRuntime( + runtime, + () => codexRuntimeHome!.prepareForCodexLaunch(), + () => store!.getSettings(), + () => claudeRuntimeAuth!.prepareForClaudeLaunch(), + store + ) + // Why: headless servers have no renderer graph publisher. Publish an + // explicit empty graph so status clients see a ready server while + // renderer-only operations still fail at their own window boundary. + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + await runtimeRpc.start().catch((error) => { + console.error('[runtime] Failed to start headless RPC transport:', error) + throw error + }) + installServeSignalHandlers() + await printServeReady(serveOptions) + return + } // Why: once the hook server is ready (or has already failed open), window // creation and runtime RPC startup are independent. diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts index a67e6d8a787..376e867da43 100644 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts @@ -179,7 +179,16 @@ describe('fs:importExternalPaths — SSH operations', () => { connectionId: connId }) expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) - expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`) + expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, { + allowExisting: false + }) + expect(uploadDirMock).toHaveBeenCalledWith( + mockSftp, + path.resolve('/tmp/dropped/assets'), + `${destDir}/assets`, + path.resolve('/tmp/dropped/assets'), + { exclusive: true } + ) }) it('reports per-item failure when deconfliction throws', async () => { diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index 210fbfe1105..e348f2f2979 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -1,4 +1,6 @@ import path from 'path' +import { constants } from 'fs' +import { Readable, Writable } from 'stream' import { beforeEach, describe, expect, it, vi } from 'vitest' const handlers = new Map Promise>() @@ -8,7 +10,9 @@ const { mkdirMock, realpathMock, copyFileMock, + openMock, readdirMock, + unlinkMock, sftpExistsMock, uploadFileMock, uploadDirMock, @@ -20,7 +24,9 @@ const { mkdirMock: vi.fn(), realpathMock: vi.fn(), copyFileMock: vi.fn(), + openMock: vi.fn(), readdirMock: vi.fn(), + unlinkMock: vi.fn(), sftpExistsMock: vi.fn(), uploadFileMock: vi.fn(), uploadDirMock: vi.fn(), @@ -36,7 +42,10 @@ vi.mock('fs/promises', () => ({ writeFile: vi.fn(), realpath: realpathMock, copyFile: copyFileMock, - readdir: readdirMock + open: openMock, + readdir: readdirMock, + unlink: unlinkMock, + rm: vi.fn() })) vi.mock('../ssh/sftp-upload', () => ({ sftpPathExists: sftpExistsMock, @@ -92,7 +101,9 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { mkdirMock, realpathMock, copyFileMock, + openMock, readdirMock, + unlinkMock, sftpExistsMock, uploadFileMock, uploadDirMock, @@ -105,6 +116,30 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { }) realpathMock.mockImplementation(async (p: string) => p) lstatMock.mockRejectedValue(enoent()) + openMock.mockImplementation(async (_p: string, flags: unknown) => { + if (flags === 'wx') { + return { + createWriteStream: () => + new Writable({ + write(_chunk, _encoding, callback) { + callback() + } + }), + close: vi.fn().mockResolvedValue(undefined) + } + } + return { + stat: vi.fn().mockResolvedValue({ + size: 12, + ino: 1, + dev: 1, + isFile: () => true + }), + createReadStream: () => Readable.from([Buffer.from('file-content')]), + close: vi.fn().mockResolvedValue(undefined) + } + }) + unlinkMock.mockResolvedValue(undefined) sftpExistsMock.mockResolvedValue(false) uploadFileMock.mockResolvedValue(undefined) uploadDirMock.mockResolvedValue(undefined) @@ -121,7 +156,12 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { connectionId: connId }) expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' }) - expect(uploadFileMock).toHaveBeenCalled() + expect(uploadFileMock).toHaveBeenCalledWith( + mockSftp, + path.resolve('/tmp/dropped/file.txt'), + `${destDir}/file.txt`, + { exclusive: true } + ) expect(copyFileMock).not.toHaveBeenCalled() }) @@ -132,7 +172,10 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { destDir: path.resolve('/workspace/repo/src') }) expect(results[0]).toMatchObject({ status: 'imported' }) - expect(copyFileMock).toHaveBeenCalled() + expect(openMock).toHaveBeenCalledWith( + path.resolve('/tmp/dropped/file.txt'), + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) }) it('returns empty results without opening SFTP', async () => { diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index 8554b466547..c29dbd6ee04 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -1,4 +1,4 @@ -import { lstat, readdir } from 'fs/promises' +import { lstat, readdir, realpath } from 'fs/promises' import { basename, join, posix, resolve } from 'path' import type { SFTPWrapper } from 'ssh2' import { authorizeExternalPath, isENOENT } from './filesystem-auth' @@ -124,10 +124,12 @@ async function importOneSourceSsh( const renamed = finalName !== originalName if (isDir) { - await mkdirSftp(sftp, destPath) - await uploadDirectory(sftp, resolvedSource, destPath) + await mkdirSftp(sftp, destPath, { allowExisting: false }) + await uploadDirectory(sftp, resolvedSource, destPath, await realpath(resolvedSource), { + exclusive: true + }) } else { - await uploadFile(sftp, resolvedSource, destPath) + await uploadFile(sftp, resolvedSource, destPath, { exclusive: true }) } return { diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index a6dc5df44b1..318e2d120fc 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -1,17 +1,34 @@ +/* eslint-disable max-lines -- Why: import tests cover local copy, SSH routing, +symlink safety, and runtime-upload staging against one shared IPC fixture. */ import path from 'path' +import { constants } from 'fs' +import { Readable, Writable } from 'stream' import { beforeEach, describe, expect, it, vi } from 'vitest' const handlers = new Map Promise>() -const { handleMock, lstatMock, mkdirMock, realpathMock, copyFileMock, readdirMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - lstatMock: vi.fn(), - mkdirMock: vi.fn(), - realpathMock: vi.fn(), - copyFileMock: vi.fn(), - readdirMock: vi.fn() - }) -) +const { + handleMock, + lstatMock, + mkdirMock, + realpathMock, + copyFileMock, + openMock, + readFileMock, + readdirMock, + rmMock, + unlinkMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + lstatMock: vi.fn(), + mkdirMock: vi.fn(), + realpathMock: vi.fn(), + copyFileMock: vi.fn(), + openMock: vi.fn(), + readFileMock: vi.fn(), + readdirMock: vi.fn(), + rmMock: vi.fn(), + unlinkMock: vi.fn() +})) vi.mock('electron', () => ({ ipcMain: { handle: handleMock } @@ -20,11 +37,15 @@ vi.mock('electron', () => ({ vi.mock('fs/promises', () => ({ lstat: lstatMock, mkdir: mkdirMock, + open: openMock, rename: vi.fn(), writeFile: vi.fn(), realpath: realpathMock, copyFile: copyFileMock, - readdir: readdirMock + readFile: readFileMock, + readdir: readdirMock, + rm: rmMock, + unlink: unlinkMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' @@ -50,7 +71,14 @@ describe('fs:importExternalPaths', () => { const resolvedPath = path.resolve(filePath) lstatMock.mockImplementation(async (p: string) => { if (p === resolvedPath) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + return { + size: 12, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } } throw enoent() }) @@ -62,6 +90,17 @@ describe('fs:importExternalPaths', () => { if (p === resolvedDir) { return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } } + const entry = entries.find((e) => path.join(resolvedDir, e.name) === p) + if (entry) { + return { + size: entry.isDir ? 0 : 12, + ino: entry.isDir ? 2 : 3, + dev: 1, + isFile: () => !entry.isDir, + isDirectory: () => entry.isDir, + isSymbolicLink: () => false + } + } throw enoent() }) readdirMock.mockImplementation(async () => { @@ -84,6 +123,35 @@ describe('fs:importExternalPaths', () => { }) } + function mockLocalCopyOpenSuccess(content = Buffer.from('file-content')): void { + openMock.mockImplementation(async (_p: string, flags: unknown) => { + if (flags === 'wx') { + const written: Buffer[] = [] + return { + createWriteStream: () => + new Writable({ + write(chunk, _encoding, callback) { + written.push(Buffer.from(chunk)) + callback() + } + }), + close: vi.fn().mockResolvedValue(undefined), + written + } + } + return { + stat: vi.fn().mockResolvedValue({ + size: content.byteLength, + ino: 1, + dev: 1, + isFile: () => true + }), + createReadStream: () => Readable.from([content]), + close: vi.fn().mockResolvedValue(undefined) + } + }) + } + beforeEach(() => { handlers.clear() handleMock.mockReset() @@ -91,7 +159,11 @@ describe('fs:importExternalPaths', () => { mkdirMock.mockReset() realpathMock.mockReset() copyFileMock.mockReset() + openMock.mockReset() + readFileMock.mockReset() readdirMock.mockReset() + rmMock.mockReset() + unlinkMock.mockReset() handleMock.mockImplementation((channel: string, handler: never) => { handlers.set(channel, handler) @@ -101,7 +173,11 @@ describe('fs:importExternalPaths', () => { lstatMock.mockRejectedValue(enoent()) mkdirMock.mockResolvedValue(undefined) copyFileMock.mockResolvedValue(undefined) + mockLocalCopyOpenSuccess() + readFileMock.mockResolvedValue(Buffer.from('file-content')) readdirMock.mockResolvedValue([]) + rmMock.mockResolvedValue(undefined) + unlinkMock.mockResolvedValue(undefined) registerFilesystemMutationHandlers(store as never) }) @@ -122,10 +198,44 @@ describe('fs:importExternalPaths', () => { renamed: false, destPath: path.join(destDir, 'logo.png') }) - expect(copyFileMock).toHaveBeenCalledWith( + expect(openMock).toHaveBeenCalledWith( path.resolve(sourcePath), - path.join(destDir, 'logo.png') + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) ) + expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx') + expect(copyFileMock).not.toHaveBeenCalled() + }) + + it('fails local import instead of clobbering when the chosen destination appears late', async () => { + const sourcePath = '/tmp/dropped/logo.png' + mockSourceFile(sourcePath) + openMock.mockImplementation(async (_p: string, flags: unknown) => { + if (flags === 'wx') { + throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) + } + return { + stat: vi.fn().mockResolvedValue({ + size: 12, + ino: 1, + dev: 1, + isFile: () => true + }), + createReadStream: () => Readable.from([Buffer.from('file-content')]), + close: vi.fn().mockResolvedValue(undefined) + } + }) + + const result = (await handlers.get('fs:importExternalPaths')!(null, { + sourcePaths: [sourcePath], + destDir + })) as { results: { status: string; reason?: string }[] } + + expect(result.results[0]).toMatchObject({ status: 'failed', reason: 'EEXIST' }) + expect(openMock).toHaveBeenCalledWith( + path.resolve(sourcePath), + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) + expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx') }) it('imports multiple files in one batch', async () => { @@ -276,6 +386,44 @@ describe('fs:importExternalPaths', () => { expect(copyFileMock).not.toHaveBeenCalled() }) + it('fails and removes output if a local directory entry becomes a symlink after pre-scan', async () => { + const sourcePath = '/tmp/dropped/mixeddir' + const resolvedSource = path.resolve(sourcePath) + const childPath = path.join(resolvedSource, 'normal.txt') + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedSource) { + return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } + } + if (p === childPath) { + return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true } + } + throw enoent() + }) + readdirMock.mockResolvedValue([ + { + name: 'normal.txt', + isDirectory: () => false, + isSymbolicLink: () => false, + isFile: () => true + } + ]) + + const result = (await handlers.get('fs:importExternalPaths')!(null, { + sourcePaths: [sourcePath], + destDir + })) as { results: { status: string; reason?: string }[] } + + expect(result.results[0]).toMatchObject({ + status: 'failed', + reason: "Symlink not allowed in 'normal.txt'" + }) + expect(openMock).not.toHaveBeenCalledWith(childPath, expect.anything()) + expect(rmMock).toHaveBeenCalledWith(path.join(destDir, 'mixeddir'), { + recursive: true, + force: true + }) + }) + it('rejects unauthorized destinations', async () => { const sourcePath = '/tmp/dropped/file.txt' mockSourceFile(sourcePath) @@ -328,4 +476,118 @@ describe('fs:importExternalPaths', () => { reason: 'missing' }) }) + + it('stages external files for runtime upload without copying into the local worktree', async () => { + const sourcePath = '/tmp/dropped/logo.png' + const resolvedPath = path.resolve(sourcePath) + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedPath) { + return { + size: 4, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + const closeMock = vi.fn().mockResolvedValue(undefined) + const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png')) + openMock.mockResolvedValue({ + stat: vi.fn().mockResolvedValue({ + size: 4, + ino: 1, + dev: 1, + isFile: () => true + }), + readFile: readFileHandleMock, + close: closeMock + }) + + const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { + sourcePaths: [sourcePath] + })) as { sources: unknown[] } + + expect(result.sources).toEqual([ + { + sourcePath, + status: 'staged', + name: 'logo.png', + kind: 'file', + entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }] + } + ]) + expect(copyFileMock).not.toHaveBeenCalled() + expect(readFileHandleMock).toHaveBeenCalled() + expect(closeMock).toHaveBeenCalled() + }) + + it('fails runtime upload staging when a file changes between lstat and open', async () => { + const sourcePath = '/tmp/dropped/logo.png' + const resolvedPath = path.resolve(sourcePath) + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedPath) { + return { + size: 4, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png')) + openMock.mockResolvedValue({ + stat: vi.fn().mockResolvedValue({ + size: 4, + ino: 2, + dev: 1, + isFile: () => true + }), + readFile: readFileHandleMock, + close: vi.fn().mockResolvedValue(undefined) + }) + + const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { + sourcePaths: [sourcePath] + })) as { sources: { status: string; reason?: string }[] } + + expect(result.sources[0]).toMatchObject({ + status: 'failed', + reason: "File changed during upload staging: ''" + }) + expect(readFileHandleMock).not.toHaveBeenCalled() + }) + + it('fails runtime upload staging when a checked directory resolves outside the upload root', async () => { + const sourcePath = '/tmp/dropped/assets' + const resolvedPath = path.resolve(sourcePath) + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedPath) { + return { + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => false + } + } + throw enoent() + }) + readdirMock.mockResolvedValue([]) + realpathMock + .mockResolvedValueOnce(resolvedPath) + .mockResolvedValueOnce(path.resolve('/private/assets')) + + const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { + sourcePaths: [sourcePath] + })) as { sources: { status: string; reason?: string }[] } + + expect(result.sources[0]).toMatchObject({ + status: 'failed', + reason: "Path escaped upload root during staging: ''" + }) + }) }) diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 3865fc1ef37..13c4a648acc 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -2,16 +2,16 @@ import path from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' const handlers = new Map Promise>() -const { handleMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } = vi.hoisted( - () => ({ +const { handleMock, copyFileMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } = + vi.hoisted(() => ({ handleMock: vi.fn(), + copyFileMock: vi.fn(), lstatMock: vi.fn(), mkdirMock: vi.fn(), renameMock: vi.fn(), writeFileMock: vi.fn(), realpathMock: vi.fn() - }) -) + })) vi.mock('electron', () => ({ ipcMain: { handle: handleMock } @@ -23,11 +23,15 @@ vi.mock('fs/promises', () => ({ rename: renameMock, writeFile: writeFileMock, realpath: realpathMock, - copyFile: vi.fn(), + copyFile: copyFileMock, readdir: vi.fn() })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' // Why: paths are resolved via path.resolve() in production code, so test // data must use resolved paths to avoid Unix-vs-Windows mismatches. @@ -58,6 +62,7 @@ describe('registerFilesystemMutationHandlers', () => { beforeEach(() => { handlers.clear() handleMock.mockReset() + copyFileMock.mockReset() lstatMock.mockReset() mkdirMock.mockReset() renameMock.mockReset() @@ -74,6 +79,7 @@ describe('registerFilesystemMutationHandlers', () => { mkdirMock.mockResolvedValue(undefined) writeFileMock.mockResolvedValue(undefined) renameMock.mockResolvedValue(undefined) + copyFileMock.mockResolvedValue(undefined) registerFilesystemMutationHandlers(store as never) }) @@ -210,6 +216,36 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) + // ── fs:copy ──────────────────────────────────────────────────── + + it('copies a file without overwriting an existing destination', async () => { + const sourcePath = path.resolve('/workspace/repo/source.ts') + const destinationPath = path.resolve('/workspace/repo/source copy.ts') + + await handlers.get('fs:copy')!(null, { sourcePath, destinationPath }) + + expect(mkdirMock).toHaveBeenCalledWith(path.resolve('/workspace/repo'), { recursive: true }) + expect(copyFileMock).toHaveBeenCalledWith(sourcePath, destinationPath, expect.any(Number)) + }) + + it('routes copy through the SSH filesystem provider when a connection is present', async () => { + const copy = vi.fn().mockResolvedValue(undefined) + registerSshFilesystemProvider('ssh-1', { copy } as never) + + try { + await handlers.get('fs:copy')!(null, { + sourcePath: '/home/me/repo/source.ts', + destinationPath: '/home/me/repo/source copy.ts', + connectionId: 'ssh-1' + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(copy).toHaveBeenCalledWith('/home/me/repo/source.ts', '/home/me/repo/source copy.ts') + expect(copyFileMock).not.toHaveBeenCalled() + }) + // ── Edge cases ───────────────────────────────────────────────── it('propagates non-ENOENT lstat errors in assertNotExists', async () => { diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index b9bcd91bedd..528baa1737c 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -1,6 +1,21 @@ +/* eslint-disable max-lines -- Why: filesystem mutation IPC handlers stay centralized so +authorization, SSH routing, and external import behavior remain audited together. */ import { ipcMain } from 'electron' -import { copyFile, lstat, mkdir, readdir, rename, writeFile } from 'fs/promises' -import { basename, dirname, join, resolve } from 'path' +import { constants } from 'fs' +import { + copyFile, + lstat, + mkdir, + open, + readdir, + realpath, + rename, + rm, + unlink, + writeFile +} from 'fs/promises' +import { basename, dirname, isAbsolute, join, relative, resolve } from 'path' +import { pipeline } from 'stream/promises' import type { Store } from '../persistence' import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' @@ -117,14 +132,42 @@ export function registerFilesystemMutationHandlers(store: Store): void { } ) + ipcMain.handle( + 'fs:copy', + async ( + _event, + args: { sourcePath: string; destinationPath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + if (!provider) { + throw new Error(`No filesystem provider for connection "${args.connectionId}"`) + } + return provider.copy(args.sourcePath, args.destinationPath) + } + const sourcePath = await resolveAuthorizedPath(args.sourcePath, store, { + preserveSymlink: true + }) + const destinationPath = await resolveAuthorizedPath(args.destinationPath, store, { + preserveSymlink: true + }) + await mkdir(dirname(destinationPath), { recursive: true }) + // Why: duplicate/copy callers deconflict before copying. COPYFILE_EXCL + // keeps a late race from silently overwriting an existing file. + await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) + } + ) + ipcMain.handle( 'fs:importExternalPaths', async ( _event, - args: { sourcePaths: string[]; destDir: string; connectionId?: string } + args: { sourcePaths: string[]; destDir: string; connectionId?: string; ensureDir?: boolean } ): Promise<{ results: ImportItemResult[] }> => { if (args.connectionId) { - return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId) + return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, { + ensureDir: args.ensureDir + }) } // Why: destDir must be authorized before any copy work begins. If the @@ -148,6 +191,20 @@ export function registerFilesystemMutationHandlers(store: Store): void { } ) + ipcMain.handle( + 'fs:stageExternalPathsForRuntimeUpload', + async ( + _event, + args: { sourcePaths: string[] } + ): Promise<{ sources: StagedExternalImportSource[] }> => { + const sources: StagedExternalImportSource[] = [] + for (const sourcePath of args.sourcePaths) { + sources.push(await stageOneSourceForRuntimeUpload(sourcePath)) + } + return { sources } + } + ) + // Why: terminal drag-and-drop resolver. Local worktrees pass paths through // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees // upload each path into `${worktreePath}/.orca/drops/` and return remote @@ -217,6 +274,32 @@ export type ImportItemResult = reason: string } +export type StagedExternalImportSource = + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: StagedExternalImportEntry[] + } + | { + sourcePath: string + status: 'skipped' + reason: ImportSkipReason + } + | { + sourcePath: string + status: 'failed' + reason: string + } + +export type StagedExternalImportEntry = + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + +const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 +const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 + // ─── External Import Implementation ───────────────────────────────── /** @@ -288,8 +371,13 @@ async function importOneSource( const renamed = finalName !== originalName try { - await (isDir ? recursiveCopyDir(resolvedSource, destPath) : copyFile(resolvedSource, destPath)) + await (isDir + ? recursiveCopyDir(resolvedSource, destPath) + : copyLocalFileNoFollow(resolvedSource, destPath)) } catch (error) { + if (isDir) { + await rm(destPath, { recursive: true, force: true }).catch(() => {}) + } return { sourcePath, status: 'failed', @@ -306,6 +394,194 @@ async function importOneSource( } } +async function stageOneSourceForRuntimeUpload( + sourcePath: string +): Promise { + const resolvedSource = resolve(sourcePath) + + // Why: runtime uploads read client-local paths in the client main process; + // authorize before lstat/readFile just like local copy imports. + authorizeExternalPath(resolvedSource) + + let sourceStat: Awaited> + try { + sourceStat = await lstat(resolvedSource) + } catch (error) { + if (isENOENT(error)) { + return { sourcePath, status: 'skipped', reason: 'missing' } + } + if ( + error instanceof Error && + 'code' in error && + ((error as NodeJS.ErrnoException).code === 'EACCES' || + (error as NodeJS.ErrnoException).code === 'EPERM') + ) { + return { sourcePath, status: 'skipped', reason: 'permission-denied' } + } + return { + sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + } + } + + if (sourceStat.isSymbolicLink()) { + return { sourcePath, status: 'skipped', reason: 'symlink' } + } + if (!sourceStat.isFile() && !sourceStat.isDirectory()) { + return { sourcePath, status: 'skipped', reason: 'unsupported' } + } + if (sourceStat.isDirectory() && (await preScanForSymlinks(resolvedSource))) { + return { sourcePath, status: 'skipped', reason: 'symlink' } + } + + try { + const entries = sourceStat.isDirectory() + ? await stageDirectoryEntries(resolvedSource) + : [await stageFileEntry(resolvedSource, '')] + return { + sourcePath, + status: 'staged', + name: basename(resolvedSource), + kind: sourceStat.isDirectory() ? 'directory' : 'file', + entries + } + } catch (error) { + return { + sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + } + } +} + +async function stageDirectoryEntries(rootPath: string): Promise { + const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }] + let totalBytes = 0 + const rootRealPath = await realpath(rootPath) + + async function visit(dirPath: string): Promise { + const dirStat = await lstat(dirPath) + if (dirStat.isSymbolicLink()) { + throw new Error( + `Symlink not allowed in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'` + ) + } + if (!dirStat.isDirectory()) { + throw new Error( + `Unsupported file type in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'` + ) + } + await assertRealPathInsideRoot( + rootRealPath, + dirPath, + normalizeRelativeUploadPath(relative(rootPath, dirPath)) + ) + const dirEntries = await readdir(dirPath, { withFileTypes: true }) + for (const entry of dirEntries) { + const childPath = join(dirPath, entry.name) + const childRelativePath = normalizeRelativeUploadPath(relative(rootPath, childPath)) + if (entry.isDirectory()) { + const childStat = await lstat(childPath) + if (childStat.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${childRelativePath}'`) + } + if (!childStat.isDirectory()) { + throw new Error(`Unsupported file type in '${childRelativePath}'`) + } + entries.push({ relativePath: childRelativePath, kind: 'directory' }) + await visit(childPath) + continue + } + if (!entry.isFile()) { + throw new Error(`Unsupported file type in '${childRelativePath}'`) + } + const statResult = await lstat(childPath) + totalBytes += statResult.size + assertRemoteUploadBudget(childRelativePath, statResult.size, totalBytes) + entries.push(await stageFileEntry(childPath, childRelativePath, rootRealPath)) + } + } + + await visit(rootPath) + return entries +} + +async function stageFileEntry( + filePath: string, + relativePath: string, + rootRealPath?: string +): Promise { + const statResult = await lstat(filePath) + const displayPath = normalizeRelativeUploadPath(relativePath) + if (statResult.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${displayPath}'`) + } + if (!statResult.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if (rootRealPath) { + await assertRealPathInsideRoot(rootRealPath, filePath, displayPath) + } + assertRemoteUploadBudget(relativePath, statResult.size, statResult.size) + const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const openedStat = await fileHandle.stat() + if (!openedStat.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if ( + openedStat.size !== statResult.size || + (statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) || + (statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev) + ) { + throw new Error(`File changed during upload staging: '${displayPath}'`) + } + assertRemoteUploadBudget(relativePath, openedStat.size, openedStat.size) + const buffer = await fileHandle.readFile() + const afterReadStat = await fileHandle.stat() + if (afterReadStat.size !== openedStat.size) { + throw new Error(`File changed during upload staging: '${displayPath}'`) + } + return { + relativePath: displayPath, + kind: 'file', + contentBase64: buffer.toString('base64') + } + } finally { + await fileHandle.close() + } +} + +async function assertRealPathInsideRoot( + rootRealPath: string, + candidatePath: string, + displayPath: string +): Promise { + const candidateRealPath = await realpath(candidatePath) + const relativeToRoot = relative(rootRealPath, candidateRealPath) + if (relativeToRoot !== '' && (relativeToRoot.startsWith('..') || isAbsolute(relativeToRoot))) { + throw new Error(`Path escaped upload root during staging: '${displayPath}'`) + } +} + +function assertRemoteUploadBudget( + relativePath: string, + fileBytes: number, + totalBytes: number +): void { + if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + throw new Error(`'${relativePath}' is too large for remote import`) + } + if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) { + throw new Error('Remote import is too large') + } +} + +function normalizeRelativeUploadPath(path: string): string { + return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +} + /** * Pre-scan a directory tree for symlinks. Returns true if any symlink * is found anywhere in the subtree. @@ -332,12 +608,72 @@ async function preScanForSymlinks(dirPath: string): Promise { * buffering entire files into memory. */ async function recursiveCopyDir(srcDir: string, destDir: string): Promise { - await mkdir(destDir, { recursive: true }) + await mkdir(destDir, { recursive: false }) const entries = await readdir(srcDir, { withFileTypes: true }) for (const entry of entries) { const srcPath = join(srcDir, entry.name) const dstPath = join(destDir, entry.name) - await (entry.isDirectory() ? recursiveCopyDir(srcPath, dstPath) : copyFile(srcPath, dstPath)) + const statResult = await lstat(srcPath) + if (statResult.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${entry.name}'`) + } + if (statResult.isDirectory()) { + await recursiveCopyDir(srcPath, dstPath) + continue + } + if (!statResult.isFile()) { + throw new Error(`Unsupported file type in '${entry.name}'`) + } + await copyLocalFileNoFollow(srcPath, dstPath, statResult) + } +} + +async function copyLocalFileNoFollow( + srcPath: string, + dstPath: string, + statResult?: Awaited> +): Promise { + const beforeOpenStat = statResult ?? (await lstat(srcPath)) + if (beforeOpenStat.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${basename(srcPath)}'`) + } + if (!beforeOpenStat.isFile()) { + throw new Error(`Unsupported file type in '${basename(srcPath)}'`) + } + + let destinationCreated = false + const sourceHandle = await open(srcPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + let destinationHandle: Awaited> | null = null + try { + const openedStat = await sourceHandle.stat() + if ( + !openedStat.isFile() || + (typeof beforeOpenStat.size === 'number' && openedStat.size !== beforeOpenStat.size) || + (typeof beforeOpenStat.ino === 'number' && + beforeOpenStat.ino !== 0 && + openedStat.ino !== 0 && + openedStat.ino !== beforeOpenStat.ino) || + (typeof beforeOpenStat.dev === 'number' && + beforeOpenStat.dev !== 0 && + openedStat.dev !== 0 && + openedStat.dev !== beforeOpenStat.dev) + ) { + throw new Error(`File changed during import: '${basename(srcPath)}'`) + } + // Why: copyFile(path, path) would follow a source symlink if the source is + // swapped after validation. Streaming from an O_NOFOLLOW handle keeps the + // authorized file identity pinned for the copy. + destinationHandle = await open(dstPath, 'wx') + destinationCreated = true + await pipeline(sourceHandle.createReadStream(), destinationHandle.createWriteStream()) + } catch (error) { + if (destinationCreated) { + await unlink(dstPath).catch(() => {}) + } + throw error + } finally { + await sourceHandle.close().catch(() => {}) + await destinationHandle?.close().catch(() => {}) } } diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index d1e3bafdebb..b6727bcf31f 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -28,16 +28,25 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ })) import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' +import { stat } from 'fs/promises' +import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' type HandlerMap = Record Promise | unknown> describe('registerFilesystemWatcherHandlers', () => { const handlers: HandlerMap = {} + const originalPlatform = process.platform beforeEach(() => { vi.useRealTimers() handleMock.mockReset() getSshFilesystemProviderMock.mockReset() + vi.mocked(stat).mockReset() + vi.mocked(subscribeParcelWatcher).mockReset() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) for (const key of Object.keys(handlers)) { delete handlers[key] } @@ -47,6 +56,28 @@ describe('registerFilesystemWatcherHandlers', () => { registerFilesystemWatcherHandlers() }) + it('pins Parcel to the Windows backend for local Windows watches', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never) + + await handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: 'C:\\repo' } + ) + + expect(subscribeParcelWatcher).toHaveBeenCalledWith( + expect.any(String), + expect.any(Function), + expect.objectContaining({ backend: 'windows' }) + ) + + await closeAllWatchers() + }) + it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => { vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 7ffbf3ae778..d6064b4696d 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -239,6 +239,14 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { @@ -280,9 +288,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise ({ + handleMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock } +})) + +vi.mock('qrcode', () => ({ + default: { + toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,qr') + } +})) + +import { registerMobileHandlers } from './mobile' + +describe('registerMobileHandlers', () => { + const handlers = new Map unknown>() + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + }) + + it('lists only paired mobile-scoped devices', () => { + const rpcServer = { + getDeviceRegistry: () => ({ + listDevices: () => [ + { + deviceId: 'mobile-1', + name: 'Phone', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + }, + { + deviceId: 'runtime-1', + name: 'CLI', + scope: 'runtime', + pairedAt: 1, + lastSeenAt: 2 + }, + { + deviceId: 'pending-mobile', + name: 'Pending', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 0 + } + ] + }) + } + + registerMobileHandlers(rpcServer as never) + + expect(handlers.get('mobile:listDevices')?.()).toEqual({ + devices: [ + { + deviceId: 'mobile-1', + name: 'Phone', + pairedAt: 1, + lastSeenAt: 2 + } + ] + }) + }) +}) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 9b1d8be3502..110f16d2699 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,7 +2,6 @@ import { ipcMain } from 'electron' import { networkInterfaces } from 'os' import QRCode from 'qrcode' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' export type NetworkInterface = { name: string @@ -46,12 +45,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { ipcMain.handle( 'mobile:getPairingQR', async (_event, args?: { address?: string; rotate?: boolean }) => { - const rawEndpoint = rpcServer.getWebSocketEndpoint() - const registry = rpcServer.getDeviceRegistry() - if (!rawEndpoint || !registry) { - return { available: false as const } - } - // Why: allow the caller to specify which network interface address to // embed in the QR code. This supports overlay networks (Tailscale, // ZeroTier) where the default LAN IP isn't reachable from the phone. @@ -59,7 +52,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { if (!ip) { return { available: false as const } } - const endpoint = rawEndpoint.replace('0.0.0.0', ip) // Why: coalesce repeated QR regenerations onto a single never-scanned // pending token so the copy-button flow doesn't accumulate orphaned @@ -68,24 +60,17 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { // `rotate: true` (explicit "Regenerate" intent because the prior token // may have been exposed), we discard any pending token and mint a fresh // one so the new QR carries a different credential. - const name = `Mobile ${new Date().toLocaleDateString()}` - const device = args?.rotate - ? registry.rotatePendingDevice(name) - : registry.getOrCreatePendingDevice(name) - - const publicKeyB64 = rpcServer.getE2EEPublicKey() - if (!publicKeyB64) { + const offer = rpcServer.createPairingOffer({ + address: ip, + rotate: args?.rotate, + name: `Mobile ${new Date().toLocaleDateString()}`, + scope: 'mobile' + }) + if (!offer.available) { return { available: false as const } } - const url = encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken: device.token, - publicKeyB64 - }) - - const qrDataUrl = await QRCode.toDataURL(url, { + const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, { errorCorrectionLevel: 'M', margin: 2, width: 256 @@ -94,9 +79,9 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { return { available: true as const, qrDataUrl, - pairingUrl: url, - endpoint, - deviceId: device.deviceId + pairingUrl: offer.pairingUrl, + endpoint: offer.endpoint, + deviceId: offer.deviceId } } ) @@ -112,7 +97,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { return { devices: registry .listDevices() - .filter((d) => d.lastSeenAt > 0) + .filter((d) => d.scope === 'mobile' && d.lastSeenAt > 0) .map((d) => ({ deviceId: d.deviceId, name: d.name, @@ -127,7 +112,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { if (!registry) { return { revoked: false } } - return { revoked: registry.removeDevice(args.deviceId) } + return { revoked: rpcServer.revokeMobileDevice(args.deviceId) } }) ipcMain.handle('mobile:isWebSocketReady', () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 7c483712e69..2b304baea2b 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -931,6 +931,13 @@ export function registerPtyHandlers( return null } }, + hasChildProcesses: async (ptyId) => { + try { + return await getProviderForPty(ptyId).hasChildProcesses(ptyId) + } catch { + return false + } + }, clearBuffer: async (ptyId) => { // Why: desktop xterm owns local scrollback, while daemon/SSH providers // own their own retained buffers. Clear both surfaces so mobile @@ -1620,6 +1627,34 @@ export function registerPtyHandlers( ) } +export function registerHeadlessPtyRuntime( + runtime: OrcaRuntimeService, + getSelectedCodexHomePath?: () => string | null, + getSettings?: () => GlobalSettings, + prepareClaudeAuth?: () => Promise, + store?: Store +): void { + // Why: headless `orca serve` has no renderer window, but the runtime still + // needs the same PTY controller and provider listeners as desktop so remote + // clients can create, stream, inspect, and stop terminals. + const headlessWindow = { + isDestroyed: () => true, + webContents: { + send: () => {}, + on: () => {}, + removeListener: () => {} + } + } as unknown as BrowserWindow + registerPtyHandlers( + headlessWindow, + runtime, + getSelectedCodexHomePath, + getSettings, + prepareClaudeAuth, + store + ) +} + /** * Kill all PTY processes. Call on app quit. */ diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index eb6fdd43d6a..aab98e262f9 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -24,6 +24,7 @@ const { registerUIHandlersMock, registerFilesystemHandlersMock, registerRuntimeHandlersMock, + registerRuntimeEnvironmentHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, registerAgentTrustHandlersMock, @@ -65,6 +66,7 @@ const { registerUIHandlersMock: vi.fn(), registerFilesystemHandlersMock: vi.fn(), registerRuntimeHandlersMock: vi.fn(), + registerRuntimeEnvironmentHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), registerAgentTrustHandlersMock: vi.fn(), @@ -194,6 +196,10 @@ vi.mock('./runtime', () => ({ registerRuntimeHandlers: registerRuntimeHandlersMock })) +vi.mock('./runtime-environments', () => ({ + registerRuntimeEnvironmentHandlers: registerRuntimeEnvironmentHandlersMock +})) + vi.mock('./codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) @@ -262,6 +268,7 @@ describe('registerCoreHandlers', () => { registerUIHandlersMock.mockReset() registerFilesystemHandlersMock.mockReset() registerRuntimeHandlersMock.mockReset() + registerRuntimeEnvironmentHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerAgentTrustHandlersMock.mockReset() @@ -328,6 +335,7 @@ describe('registerCoreHandlers', () => { expect(registerUIHandlersMock).toHaveBeenCalledWith(store) expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store) expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime) + expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled() expect(registerCliHandlersMock).toHaveBeenCalled() expect(registerPreflightHandlersMock).toHaveBeenCalled() expect(registerShellHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index e445a8e659f..74c08a40f69 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -18,6 +18,7 @@ import { registerStatsHandlers } from './stats' import { registerMemoryHandlers } from './memory' import { registerRateLimitHandlers } from './rate-limits' import { registerRuntimeHandlers } from './runtime' +import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { registerNotesHandlers } from './notes' import { registerNotificationHandlers } from './notifications' import { registerNotebookHandlers } from './notebook' @@ -120,6 +121,7 @@ export function registerCoreHandlers( registerFilesystemHandlers(store) registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) + registerRuntimeEnvironmentHandlers() registerNotesHandlers(runtime) registerClipboardHandlers() registerUpdaterHandlers(store) diff --git a/src/main/ipc/runtime-environment-call-queue.ts b/src/main/ipc/runtime-environment-call-queue.ts new file mode 100644 index 00000000000..2a06416bb94 --- /dev/null +++ b/src/main/ipc/runtime-environment-call-queue.ts @@ -0,0 +1,92 @@ +const REMOTE_RUNTIME_CALL_CONCURRENCY = 8 +const REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY = 2 + +type QueuedRuntimeCall = { + background: boolean + run: () => Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} + +type RuntimeCallQueue = { + active: number + backgroundActive: number + foreground: QueuedRuntimeCall[] + background: QueuedRuntimeCall[] +} + +const runtimeCallQueues = new Map() + +function isBackgroundRuntimeMethod(method: string): boolean { + return ( + method === 'hostedReview.forBranch' || + method === 'github.listWorkItems' || + method === 'github.countWorkItems' || + method === 'git.status' || + method === 'git.conflictOperation' || + method === 'git.branchCompare' || + method === 'git.upstreamStatus' + ) +} + +function getRuntimeCallQueue(selector: string): RuntimeCallQueue { + let queue = runtimeCallQueues.get(selector) + if (!queue) { + queue = { active: 0, backgroundActive: 0, foreground: [], background: [] } + runtimeCallQueues.set(selector, queue) + } + return queue +} + +export function enqueueRuntimeCall( + selector: string, + method: string, + run: () => Promise +): Promise { + const queue = getRuntimeCallQueue(selector) + return new Promise((resolve, reject) => { + const call: QueuedRuntimeCall = { + background: isBackgroundRuntimeMethod(method), + run, + resolve, + reject + } + const targetQueue = call.background ? queue.background : queue.foreground + targetQueue.push(call as QueuedRuntimeCall) + pumpRuntimeCallQueue(selector, queue) + }) +} + +function pumpRuntimeCallQueue(selector: string, queue: RuntimeCallQueue): void { + while (queue.active < REMOTE_RUNTIME_CALL_CONCURRENCY) { + let call = queue.foreground.shift() + if (!call && queue.backgroundActive < REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY) { + call = queue.background.shift() + } + if (!call) { + break + } + + queue.active += 1 + if (call.background) { + queue.backgroundActive += 1 + } + // Why: remote WebSocket servers have a finite connection budget and each + // one-shot RPC currently opens its own encrypted socket. Background PR/task + // refreshes must not stampede the server and starve terminal/worktree calls. + void call + .run() + .then(call.resolve, call.reject) + .finally(() => { + queue.active = Math.max(0, queue.active - 1) + if (call.background) { + queue.backgroundActive = Math.max(0, queue.backgroundActive - 1) + } + if (queue.active === 0 && queue.foreground.length === 0 && queue.background.length === 0) { + runtimeCallQueues.delete(selector) + return + } + pumpRuntimeCallQueue(selector, queue) + }) + } +} diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts new file mode 100644 index 00000000000..97cc0bf21ed --- /dev/null +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -0,0 +1,46 @@ +import type { PairingOffer } from '../../shared/pairing' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' + +type CachedRuntimeConnection = { + pairingKey: string + connection: RemoteRuntimeRequestConnection +} + +const requestConnections = new Map() + +export function sendRemoteRuntimeConnectionRequest( + environmentId: string, + pairing: PairingOffer, + method: string, + params: unknown, + timeoutMs: number +): Promise> { + const pairingKey = getPairingKey(pairing) + let cached = requestConnections.get(environmentId) + if (!cached || cached.pairingKey !== pairingKey) { + cached?.connection.close() + cached = { + pairingKey, + connection: new RemoteRuntimeRequestConnection(pairing) + } + requestConnections.set(environmentId, cached) + } + return cached.connection.request(method, params, timeoutMs) +} + +export function closeRemoteRuntimeRequestConnection(environmentId: string): void { + const cached = requestConnections.get(environmentId) + requestConnections.delete(environmentId) + cached?.connection.close() +} + +export function closeAllRemoteRuntimeRequestConnections(): void { + for (const environmentId of Array.from(requestConnections.keys())) { + closeRemoteRuntimeRequestConnection(environmentId) + } +} + +function getPairingKey(pairing: PairingOffer): string { + return [pairing.endpoint, pairing.deviceToken, pairing.publicKeyB64].join('\0') +} diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts new file mode 100644 index 00000000000..14d51953361 --- /dev/null +++ b/src/main/ipc/runtime-environments.test.ts @@ -0,0 +1,615 @@ +/* eslint-disable max-lines -- Why: this suite covers runtime environment + management, secret redaction, one-shot RPC, and streaming cleanup contracts. */ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import * as environmentStore from '../../shared/runtime-environment-store' + +const { + handleMock, + onMock, + getPathMock, + sendRemoteRuntimeRequestMock, + subscribeRemoteRuntimeRequestMock, + sendRemoteRuntimeConnectionRequestMock, + closeRemoteRuntimeRequestConnectionMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + onMock: vi.fn(), + getPathMock: vi.fn(), + sendRemoteRuntimeRequestMock: vi.fn(), + subscribeRemoteRuntimeRequestMock: vi.fn(), + sendRemoteRuntimeConnectionRequestMock: vi.fn(), + closeRemoteRuntimeRequestConnectionMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock }, + ipcMain: { handle: handleMock, on: onMock } +})) + +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock, + subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock +})) + +vi.mock('./runtime-environment-request-connections', () => ({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock +})) + +import { registerRuntimeEnvironmentHandlers } from './runtime-environments' + +function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { + return encodePairingOffer({ + v: 2, + endpoint, + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) +} + +function handler( + channel: string +): (_event: unknown, args: TArgs) => TResult | Promise { + const match = handleMock.mock.calls.find((call) => call[0] === channel) + expect(match).toBeTruthy() + return match![1] as (_event: unknown, args: TArgs) => TResult | Promise +} + +describe('registerRuntimeEnvironmentHandlers', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-ipc-')) + getPathMock.mockReset() + getPathMock.mockReturnValue(userDataPath) + handleMock.mockReset() + onMock.mockReset() + sendRemoteRuntimeRequestMock.mockReset() + subscribeRemoteRuntimeRequestMock.mockReset() + sendRemoteRuntimeConnectionRequestMock.mockReset() + closeRemoteRuntimeRequestConnectionMock.mockReset() + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('registers desktop runtime environment management handlers', () => { + registerRuntimeEnvironmentHandlers() + + expect(handleMock.mock.calls.map((call) => call[0])).toEqual([ + 'runtimeEnvironments:list', + 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:resolve', + 'runtimeEnvironments:remove', + 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:call', + 'runtimeEnvironments:subscribe', + 'runtimeEnvironments:unsubscribe' + ]) + expect(onMock.mock.calls.map((call) => call[0])).toEqual([ + 'runtimeEnvironments:subscriptionBinary' + ]) + }) + + it('stores, resolves, lists, and removes environments under Electron userData', async () => { + registerRuntimeEnvironmentHandlers() + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + expect(JSON.stringify(added)).not.toContain('device-token') + expect(JSON.stringify(added)).not.toContain('publicKeyB64') + + const list = handler('runtimeEnvironments:list') + expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }]) + expect(JSON.stringify(await list(null, undefined))).not.toContain('device-token') + + const resolve = handler<{ selector: string }, { id: string; name: string }>( + 'runtimeEnvironments:resolve' + ) + expect(await resolve(null, { selector: 'desk' })).toMatchObject({ + id: added.environment.id, + name: 'desk' + }) + expect(JSON.stringify(await resolve(null, { selector: 'desk' }))).not.toContain('device-token') + + const remove = handler<{ selector: string }, { removed: { id: string; name: string } }>( + 'runtimeEnvironments:remove' + ) + const removed = await remove(null, { selector: added.environment.id }) + expect(removed).toMatchObject({ + removed: { id: added.environment.id, name: 'desk' } + }) + expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) + expect(JSON.stringify(removed)).not.toContain('device-token') + expect(await list(null, undefined)).toEqual([]) + }) + + it('checks a saved remote runtime and records the runtime id on success', async () => { + registerRuntimeEnvironmentHandlers() + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { runtimeId: 'runtime-remote', graphStatus: 'ready' }, + _meta: { runtimeId: 'runtime-remote' } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const getStatus = handler< + { selector: string; timeoutMs?: number }, + { ok: true; result: { runtimeId: string } } + >('runtimeEnvironments:getStatus') + expect(await getStatus(null, { selector: 'desk', timeoutMs: 50 })).toMatchObject({ + ok: true, + result: { runtimeId: 'runtime-remote' } + }) + expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768', deviceToken: 'device-token' }), + 'status.get', + undefined, + 50 + ) + + const resolve = handler<{ selector: string }, { id: string; runtimeId: string | null }>( + 'runtimeEnvironments:resolve' + ) + expect(await resolve(null, { selector: added.environment.id })).toMatchObject({ + id: added.environment.id, + runtimeId: 'runtime-remote' + }) + }) + + it('proxies generic one-shot RPC calls to the saved remote runtime', async () => { + registerRuntimeEnvironmentHandlers() + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'rpc-2', + ok: true, + result: { repos: [{ id: 'repo-1' }] }, + _meta: { runtimeId: 'runtime-remote' } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const call = handler< + { selector: string; method: string; params?: unknown; timeoutMs?: number }, + { ok: true; result: unknown } + >('runtimeEnvironments:call') + expect( + await call(null, { selector: 'desk', method: 'repo.list', timeoutMs: 75 }) + ).toMatchObject({ + ok: true, + result: { repos: [{ id: 'repo-1' }] } + }) + expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }), + 'repo.list', + undefined, + 75 + ) + expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled() + }) + + it('uses the cached request connection for terminal hot path RPCs', async () => { + registerRuntimeEnvironmentHandlers() + sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({ + id: 'rpc-terminal', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime-remote' } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const call = handler< + { selector: string; method: string; params?: unknown; timeoutMs?: number }, + { ok: true; result: unknown } + >('runtimeEnvironments:call') + expect( + await call(null, { + selector: 'desk', + method: 'terminal.send', + params: { terminal: 't1', text: 'a' }, + timeoutMs: 75 + }) + ).toMatchObject({ + ok: true, + result: { send: { accepted: true } } + }) + expect(sendRemoteRuntimeConnectionRequestMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }), + 'terminal.send', + { terminal: 't1', text: 'a' }, + 75 + ) + expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled() + }) + + it('limits background one-shot RPCs without blocking foreground runtime calls', async () => { + registerRuntimeEnvironmentHandlers() + const pendingBackground: ((value: unknown) => void)[] = [] + sendRemoteRuntimeRequestMock.mockImplementation(async () => { + return await new Promise((resolve) => pendingBackground.push(resolve)) + }) + sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({ + id: 'terminal-send', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime-remote' } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const call = handler< + { selector: string; method: string; params?: unknown; timeoutMs?: number }, + { ok: true; result: unknown } + >('runtimeEnvironments:call') + const bg1 = call(null, { selector: 'desk', method: 'hostedReview.forBranch' }) + const bg2 = call(null, { selector: 'desk', method: 'github.listWorkItems' }) + await vi.waitFor(() => expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(2)) + + const bg3 = call(null, { selector: 'desk', method: 'git.status' }) + const foreground = call(null, { + selector: 'desk', + method: 'terminal.send', + params: { terminal: 'term-1', text: 'a' } + }) + await vi.waitFor(() => + expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([ + 'hostedReview.forBranch', + 'github.listWorkItems' + ]) + ) + expect(sendRemoteRuntimeConnectionRequestMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + 'terminal.send', + { terminal: 'term-1', text: 'a' }, + 15_000 + ) + + await expect(foreground).resolves.toMatchObject({ + ok: true, + result: { send: { accepted: true } } + }) + expect(pendingBackground).toHaveLength(2) + + pendingBackground.shift()?.({ + id: 'background-1', + ok: true, + result: null, + _meta: { runtimeId: 'runtime-remote' } + }) + await vi.waitFor(() => expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(3)) + expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([ + 'hostedReview.forBranch', + 'github.listWorkItems', + 'git.status' + ]) + + pendingBackground.splice(0).forEach((resolve) => + resolve({ + id: 'background', + ok: true, + result: null, + _meta: { runtimeId: 'runtime-remote' } + }) + ) + await expect(bg1).resolves.toMatchObject({ ok: true }) + await expect(bg2).resolves.toMatchObject({ ok: true }) + await expect(bg3).resolves.toMatchObject({ ok: true }) + }) + + it('starts and stops streaming subscriptions for a saved remote runtime', async () => { + registerRuntimeEnvironmentHandlers() + const close = vi.fn() + const sendBinary = vi.fn() + const markUsedSpy = vi.spyOn(environmentStore, 'markEnvironmentUsed') + subscribeRemoteRuntimeRequestMock.mockImplementation( + async (_pairing, _method, _params, _timeoutMs, callbacks) => { + callbacks.onResponse({ + id: 'stream-1', + ok: true, + result: { type: 'subscribed' }, + _meta: { runtimeId: 'runtime-remote' } + }) + callbacks.onResponse({ + id: 'stream-1', + ok: true, + result: { type: 'data', chunk: 'hello' }, + _meta: { runtimeId: 'runtime-remote' } + }) + callbacks.onBinary(new Uint8Array([1, 2, 3])) + return { requestId: 'stream-1', close, sendBinary } + } + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const sent: unknown[] = [] + const destroyedListenerRemoved = vi.fn() + const subscribe = handler< + { + selector: string + method: string + params?: unknown + timeoutMs?: number + subscriptionId?: string + }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const result = await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: (_channel: string, payload: unknown) => sent.push(payload), + once: vi.fn(), + removeListener: destroyedListenerRemoved + } + }, + { + selector: 'desk', + method: 'terminal.subscribe', + params: { terminal: 't1' }, + timeoutMs: 25, + subscriptionId: 'preload-sub-1' + } + ) + + expect(result.requestId).toBe('stream-1') + expect(result.subscriptionId).toBe('preload-sub-1') + expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }), + 'terminal.subscribe', + { terminal: 't1' }, + 25, + expect.any(Object) + ) + expect(sent).toEqual([ + expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }), + expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }), + expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'binary' }) + ]) + expect(markUsedSpy).toHaveBeenCalledTimes(1) + + const binaryListener = onMock.mock.calls.find( + (call) => call[0] === 'runtimeEnvironments:subscriptionBinary' + )?.[1] as (_event: unknown, args: unknown) => void + const bytes = new Uint8Array([9, 8, 7]) + binaryListener({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId, bytes }) + expect(sendBinary).toHaveBeenCalledWith(bytes) + + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect( + await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId }) + ).toEqual({ + unsubscribed: true + }) + expect(close).toHaveBeenCalled() + expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function)) + markUsedSpy.mockRestore() + }) + + it('rejects cross-window streaming subscription control', async () => { + registerRuntimeEnvironmentHandlers() + const close = vi.fn() + const sendBinary = vi.fn() + subscribeRemoteRuntimeRequestMock.mockResolvedValue({ + requestId: 'stream-1', + close, + sendBinary + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const subscribe = handler< + { + selector: string + method: string + params?: unknown + subscriptionId?: string + }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const result = await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + removeListener: vi.fn() + } + }, + { + selector: 'desk', + method: 'terminal.subscribe', + params: { terminal: 't1' }, + subscriptionId: 'owned-sub' + } + ) + + const binaryListener = onMock.mock.calls.find( + (call) => call[0] === 'runtimeEnvironments:subscriptionBinary' + )?.[1] as (_event: unknown, args: unknown) => void + binaryListener( + { sender: { id: 2 } }, + { subscriptionId: result.subscriptionId, bytes: new Uint8Array([1]) } + ) + expect(sendBinary).not.toHaveBeenCalled() + + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect( + await unsubscribe({ sender: { id: 2 } }, { subscriptionId: result.subscriptionId }) + ).toEqual({ + unsubscribed: false + }) + expect(close).not.toHaveBeenCalled() + + expect( + await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId }) + ).toEqual({ + unsubscribed: true + }) + expect(close).toHaveBeenCalled() + }) + + it('closes a streaming subscription that resolves after the sender is destroyed', async () => { + registerRuntimeEnvironmentHandlers() + const close = vi.fn() + let resolveSubscribe: (value: { + requestId: string + close: () => void + sendBinary: (bytes: Uint8Array) => boolean + }) => void = () => {} + subscribeRemoteRuntimeRequestMock.mockImplementation( + () => + new Promise((resolve) => { + resolveSubscribe = resolve + }) + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + let destroyed = false + let destroyedHandler: unknown = null + const destroyedListenerRemoved = vi.fn() + const subscribe = handler< + { + selector: string + method: string + params?: unknown + subscriptionId?: string + }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const resultPromise = subscribe( + { + sender: { + id: 1, + isDestroyed: () => destroyed, + send: vi.fn(), + once: vi.fn((_event: string, handler: () => void) => { + destroyedHandler = () => { + destroyed = true + handler() + } + }), + removeListener: destroyedListenerRemoved + } + }, + { + selector: 'desk', + method: 'terminal.subscribe', + params: { terminal: 't1' }, + subscriptionId: 'late-sub' + } + ) + + await vi.waitFor(() => { + expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalled() + }) + expect(destroyedHandler).toBeTypeOf('function') + ;(destroyedHandler as () => void)() + resolveSubscribe({ requestId: 'stream-late', close, sendBinary: vi.fn() }) + + await expect(resultPromise).resolves.toEqual({ + subscriptionId: 'late-sub', + requestId: 'stream-late' + }) + expect(close).toHaveBeenCalledTimes(1) + expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function)) + + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect(await unsubscribe({ sender: { id: 1 } }, { subscriptionId: 'late-sub' })).toEqual({ + unsubscribed: false + }) + }) + + it('removes the destroyed listener when streaming subscription setup rejects', async () => { + registerRuntimeEnvironmentHandlers() + subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed')) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const destroyedListenerRemoved = vi.fn() + const subscribe = handler< + { + selector: string + method: string + params?: unknown + subscriptionId?: string + }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + + await expect( + subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + removeListener: destroyedListenerRemoved + } + }, + { + selector: 'desk', + method: 'terminal.subscribe', + params: { terminal: 't1' }, + subscriptionId: 'failed-sub' + } + ) + ).rejects.toThrow('connect failed') + + expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function)) + }) +}) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts new file mode 100644 index 00000000000..9b278ba63ba --- /dev/null +++ b/src/main/ipc/runtime-environments.ts @@ -0,0 +1,313 @@ +/* eslint-disable max-lines -- Why: runtime environment IPC is the security boundary for saved server calls and subscriptions; keeping ownership checks, lifecycle cleanup, and binary forwarding together makes the bridge auditable. */ +import { app, ipcMain } from 'electron' +import { randomUUID } from 'crypto' +import { + addEnvironmentFromPairingCode, + listEnvironments, + markEnvironmentUsed, + removeEnvironment, + resolveEnvironment, + resolveEnvironmentPairingOffer +} from '../../shared/runtime-environment-store' +import { + redactRuntimeEnvironment, + getPreferredPairingOffer, + type PublicKnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { + sendRemoteRuntimeRequest, + subscribeRemoteRuntimeRequest, + type RemoteRuntimeSubscription +} from '../../shared/remote-runtime-client' +import { enqueueRuntimeCall } from './runtime-environment-call-queue' +import { + closeRemoteRuntimeRequestConnection, + sendRemoteRuntimeConnectionRequest +} from './runtime-environment-request-connections' + +const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 +type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { + ownerWebContentsId: number + removeDestroyedListener: () => void +} +const remoteRuntimeSubscriptions = new Map() + +function getUserDataPath(): string { + return app.getPath('userData') +} + +function shouldUseCachedRequestConnection(method: string): boolean { + return method === 'terminal.send' || method === 'terminal.updateViewport' +} + +export function registerRuntimeEnvironmentHandlers(): void { + ipcMain.handle('runtimeEnvironments:list', (): PublicKnownRuntimeEnvironment[] => + listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment) + ) + ipcMain.handle( + 'runtimeEnvironments:addFromPairingCode', + ( + _event, + args: { name: string; pairingCode: string } + ): { environment: PublicKnownRuntimeEnvironment } => ({ + environment: redactRuntimeEnvironment(addEnvironmentFromPairingCode(getUserDataPath(), args)) + }) + ) + ipcMain.handle( + 'runtimeEnvironments:resolve', + (_event, args: { selector: string }): PublicKnownRuntimeEnvironment => + redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector)) + ) + ipcMain.handle( + 'runtimeEnvironments:remove', + (_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => { + const removed = removeEnvironment(getUserDataPath(), args.selector) + closeRemoteRuntimeRequestConnection(removed.id) + if (args.selector !== removed.id) { + closeRemoteRuntimeRequestConnection(args.selector) + } + return { removed: redactRuntimeEnvironment(removed) } + } + ) + ipcMain.handle( + 'runtimeEnvironments:getStatus', + async ( + _event, + args: { selector: string; timeoutMs?: number } + ): Promise> => { + const userDataPath = getUserDataPath() + const response = await sendRemoteRuntimeRequest( + resolveEnvironmentPairingOffer(userDataPath, args.selector), + 'status.get', + undefined, + args.timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS + ) + if (response.ok === true) { + markEnvironmentUsed(userDataPath, args.selector, { runtimeId: response._meta.runtimeId }) + } + return response + } + ) + ipcMain.handle( + 'runtimeEnvironments:call', + async ( + _event, + args: { selector: string; method: string; params?: unknown; timeoutMs?: number } + ): Promise> => { + return callRuntimeEnvironment(args.selector, args.method, args.params, args.timeoutMs) + } + ) + ipcMain.handle( + 'runtimeEnvironments:subscribe', + async ( + event, + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + subscriptionId?: string + } + ): Promise<{ subscriptionId: string; requestId: string }> => { + const subscriptionId = + typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 + ? args.subscriptionId + : randomUUID() + if (remoteRuntimeSubscriptions.has(subscriptionId)) { + throw new Error('Runtime environment subscription id already exists') + } + const sender = event.sender + const ownerWebContentsId = sender.id + let senderDestroyed = sender.isDestroyed() + let subscription: RemoteRuntimeSubscription | null = null + let destroyedListenerAttached = false + const removeDestroyedListener = (): void => { + if (!destroyedListenerAttached) { + return + } + destroyedListenerAttached = false + sender.removeListener('destroyed', closeSubscription) + } + const closeSubscription = (): void => { + senderDestroyed = true + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + remoteRuntimeSubscriptions.delete(subscriptionId) + if (retained) { + retained.close() + return + } + removeDestroyedListener() + subscription?.close() + } + sender.once('destroyed', closeSubscription) + destroyedListenerAttached = true + try { + subscription = await subscribeRuntimeEnvironment( + args.selector, + args.method, + args.params, + args.timeoutMs, + { + onEvent: (payload) => { + if (!sender.isDestroyed()) { + sender.send('runtimeEnvironments:subscriptionEvent', { + subscriptionId, + ...payload + }) + } + }, + onClose: () => { + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + retained?.removeDestroyedListener() + remoteRuntimeSubscriptions.delete(subscriptionId) + } + } + ) + } catch (error) { + removeDestroyedListener() + throw error + } + if (senderDestroyed || sender.isDestroyed()) { + removeDestroyedListener() + subscription.close() + return { subscriptionId, requestId: subscription.requestId } + } + remoteRuntimeSubscriptions.set(subscriptionId, { + requestId: subscription.requestId, + ownerWebContentsId, + removeDestroyedListener, + sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, + close: () => { + removeDestroyedListener() + subscription?.close() + } + }) + return { subscriptionId, requestId: subscription.requestId } + } + ) + ipcMain.handle( + 'runtimeEnvironments:unsubscribe', + (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { + return { unsubscribed: false } + } + remoteRuntimeSubscriptions.delete(args.subscriptionId) + subscription.close() + return { unsubscribed: true } + } + ) + ipcMain.on( + 'runtimeEnvironments:subscriptionBinary', + (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { + if (typeof args.subscriptionId !== 'string') { + return + } + const bytes = toBinaryPayload(args.bytes) + if (!bytes) { + return + } + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (subscription?.ownerWebContentsId === event.sender.id) { + subscription.sendBinary(bytes) + } + } + ) +} + +function toBinaryPayload(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) { + return value + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value) + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + return null +} + +async function callRuntimeEnvironment( + selector: string, + method: string, + params: unknown, + timeoutMs?: number +): Promise> { + const userDataPath = getUserDataPath() + const environment = resolveEnvironment(userDataPath, selector) + return enqueueRuntimeCall(environment.id, method, async () => { + const currentEnvironment = resolveEnvironment(userDataPath, environment.id) + const pairing = getPreferredPairingOffer(currentEnvironment) + const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS + // Why: the cached request socket is only needed for terminal hot paths. + // Startup/control-plane RPCs use the proven one-shot path so repo hydration + // cannot be coupled to a stale terminal-control connection. + const response = shouldUseCachedRequestConnection(method) + ? await sendRemoteRuntimeConnectionRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs + ) + : await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) + if (response.ok === true) { + markEnvironmentUsed(userDataPath, currentEnvironment.id, { + runtimeId: response._meta.runtimeId + }) + } + return response + }) +} + +async function subscribeRuntimeEnvironment( + selector: string, + method: string, + params: unknown, + timeoutMs: number | undefined, + callbacks: { + onEvent: ( + payload: + | { type: 'response'; response: RuntimeRpcResponse } + | { type: 'binary'; bytes: Uint8Array } + | { type: 'error'; code: string; message: string } + | { type: 'close' } + ) => void + onClose: () => void + } +): Promise { + const userDataPath = getUserDataPath() + let markedUsed = false + const markUsedOnce = (runtimeId: string): void => { + if (markedUsed) { + return + } + markedUsed = true + markEnvironmentUsed(userDataPath, selector, { runtimeId }) + } + const subscription = await subscribeRemoteRuntimeRequest( + resolveEnvironmentPairingOffer(userDataPath, selector), + method, + params, + timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS, + { + onResponse: (response) => { + if (response.ok === true) { + markUsedOnce(response._meta.runtimeId) + } + callbacks.onEvent({ type: 'response', response }) + }, + onBinary: (bytes) => callbacks.onEvent({ type: 'binary', bytes }), + onError: (error) => + callbacks.onEvent({ type: 'error', code: error.code, message: error.message }), + onClose: () => { + callbacks.onEvent({ type: 'close' }) + callbacks.onClose() + } + } + ) + return subscription +} diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index 29c96559cd8..68a570e28cd 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -28,7 +28,8 @@ describe('registerRuntimeHandlers', () => { it('routes sync requests through the authoritative browser window id', () => { const runtime = { syncWindowGraph: vi.fn().mockReturnValue({ graphStatus: 'ready' }), - getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' }) + getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' }), + getRuntimeId: vi.fn().mockReturnValue('runtime-1') } registerRuntimeHandlers(runtime as never) @@ -46,4 +47,33 @@ describe('registerRuntimeHandlers', () => { expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, { tabs: [], leaves: [] }) expect(result).toEqual({ graphStatus: 'ready' }) }) + + it('routes generic local runtime RPC calls through the dispatcher', async () => { + const runtime = { + syncWindowGraph: vi.fn(), + getStatus: vi.fn().mockReturnValue({ + runtimeId: 'runtime-1', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + }), + getRuntimeId: vi.fn().mockReturnValue('runtime-1') + } + + registerRuntimeHandlers(runtime as never) + + const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call') + expect(callRegistration).toBeTruthy() + + const handler = callRegistration![1] + const result = await handler({ sender: {} }, { method: 'status.get' }) + + expect(result).toMatchObject({ + ok: true, + result: { runtimeId: 'runtime-1', graphStatus: 'ready' }, + _meta: { runtimeId: 'runtime-1' } + }) + }) }) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 14a4de52ffb..d6c91e9e96e 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -1,10 +1,13 @@ import { BrowserWindow, ipcMain } from 'electron' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { ipcMain.removeHandler('runtime:syncWindowGraph') ipcMain.removeHandler('runtime:getStatus') + ipcMain.removeHandler('runtime:call') ipcMain.handle( 'runtime:syncWindowGraph', @@ -21,6 +24,21 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { return runtime.getStatus() }) + ipcMain.handle( + 'runtime:call', + async ( + _event, + args: { method: string; params?: unknown } + ): Promise> => { + return (await new RpcDispatcher({ runtime }).dispatch({ + id: 'desktop-ipc', + authToken: 'desktop-ipc', + method: args.method, + params: args.params + })) as RuntimeRpcResponse + } + ) + ipcMain.removeHandler('runtime:getTerminalFitOverrides') ipcMain.handle( 'runtime:getTerminalFitOverrides', diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 948297d004b..066c39d981e 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -108,7 +108,7 @@ async function ensureUniqueRemoteName(repoPath: string, preferred: string): Prom throw new Error(`Could not find an available remote name for ${preferred}.`) } -async function prepareWorktreePushTarget( +export async function prepareWorktreePushTarget( repoPath: string, target: GitPushTarget ): Promise { @@ -138,7 +138,7 @@ async function prepareWorktreePushTarget( } } -async function configureCreatedWorktreePushTarget( +export async function configureCreatedWorktreePushTarget( worktreePath: string, branchName: string, target: GitPushTarget diff --git a/src/main/powershell-osc133-bootstrap.test.ts b/src/main/powershell-osc133-bootstrap.test.ts index a7219b84500..10d278088a8 100644 --- a/src/main/powershell-osc133-bootstrap.test.ts +++ b/src/main/powershell-osc133-bootstrap.test.ts @@ -13,10 +13,13 @@ describe('PowerShell OSC 133 bootstrap', () => { expect(script).toContain('ORCA_PI_CODING_AGENT_DIR') expect(script).toContain('function Global:prompt') expect(script).toContain('function Global:PSConsoleHostReadLine') - expect(script).toContain('`e]133;D;$fakeExitCode`a') - expect(script).toContain('`e]133;A`a') - expect(script).toContain('`e]133;B`a') - expect(script).toContain('`e]133;C`a') + expect(script).toContain('Esc = [char]27') + expect(script).toContain('Bel = [char]7') + expect(script).toContain(')]133;D;$fakeExitCode$(') + expect(script).toContain(')]133;A$(') + expect(script).toContain(')]133;B$(') + expect(script).toContain(')]133;C$(') + expect(script).not.toContain('`e]133') expect(script).not.toContain('$PROFILE') expect(script).not.toContain('ExecutionPolicy') expect(script).not.toContain('NoProfile') diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 1784020c468..8f87ed21d2d 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -28,6 +28,8 @@ $Global:__OrcaOsc133State = @{ OriginalReadLine = $function:PSConsoleHostReadLine HasSeenPrompt = $false HasPSReadLine = $null -ne (Get-Module -Name PSReadLine) + Esc = [char]27 + Bel = [char]7 } function Global:prompt { @@ -39,15 +41,15 @@ function Global:prompt { # Emit D from prompt, not readline state. Some profile setups bypass # PSConsoleHostReadLine; the consumer only needs completion. if ($Global:__OrcaOsc133State.HasSeenPrompt) { - $result += "\`e]133;D;$fakeExitCode\`a" + $result += "$($Global:__OrcaOsc133State.Esc)]133;D;$fakeExitCode$($Global:__OrcaOsc133State.Bel)" } $Global:__OrcaOsc133State.HasSeenPrompt = $true - $result += "\`e]133;A\`a" + $result += "$($Global:__OrcaOsc133State.Esc)]133;A$($Global:__OrcaOsc133State.Bel)" # Preserve the previous success/failure value for prompts that inspect it. if ($fakeExitCode -ne 0) { Write-Error "failure" -ea ignore } $result += $Global:__OrcaOsc133State.OriginalPrompt.Invoke() - $result += "\`e]133;B\`a" + $result += "$($Global:__OrcaOsc133State.Esc)]133;B$($Global:__OrcaOsc133State.Bel)" $result } @@ -55,7 +57,7 @@ if ($Global:__OrcaOsc133State.HasPSReadLine -and $null -ne $Global:__OrcaOsc133State.OriginalReadLine) { function Global:PSConsoleHostReadLine { $commandLine = $Global:__OrcaOsc133State.OriginalReadLine.Invoke() - [Console]::Write("\`e]133;C\`a") + [Console]::Write("$($Global:__OrcaOsc133State.Esc)]133;C$($Global:__OrcaOsc133State.Bel)") return $commandLine } } diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index dca8cf06bc9..8fbf9551c00 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: SSH filesystem provider coverage keeps relay fallback, +SFTP binary writes, watch fan-out, and provider lifecycle tests together so +transport parity regressions are visible in one suite. */ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshFilesystemProvider } from './ssh-filesystem-provider' @@ -90,6 +93,64 @@ describe('SshFilesystemProvider', () => { }) }) + describe('writeFileBase64', () => { + it('writes decoded bytes through SFTP', async () => { + const written: Buffer[] = [] + const writeStream = { + on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), + end: vi.fn((buffer: Buffer) => { + written.push(buffer) + const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1] + closeHandler?.() + }), + destroy: vi.fn() + } + const sftp = { + createWriteStream: vi.fn(() => writeStream), + end: vi.fn() + } + provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) + + await provider.writeFileBase64('/home/user/logo.png', 'cG5n') + + expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'wx' }) + expect(written).toEqual([Buffer.from('png')]) + expect(sftp.end).toHaveBeenCalled() + expect(mux.request).not.toHaveBeenCalledWith('fs.writeFile', expect.anything()) + }) + + it('can append decoded chunks through SFTP', async () => { + const writeStream = { + on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), + end: vi.fn((_buffer: Buffer) => { + const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1] + closeHandler?.() + }), + destroy: vi.fn() + } + const sftp = { + createWriteStream: vi.fn(() => writeStream), + end: vi.fn() + } + provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) + + await provider.writeFileBase64Chunk('/home/user/logo.png', 'cG5n', true) + + expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'a' }) + expect(sftp.end).toHaveBeenCalled() + }) + }) + + describe('createDirNoClobber', () => { + it('sends fs.createDirNoClobber request', async () => { + await provider.createDirNoClobber('/home/user/new-dir') + + expect(mux.request).toHaveBeenCalledWith('fs.createDirNoClobber', { + dirPath: '/home/user/new-dir' + }) + }) + }) + describe('stat', () => { it('sends fs.stat request', async () => { const statResult = { size: 1024, type: 'file', mtime: 1234567890 } @@ -196,6 +257,112 @@ describe('SshFilesystemProvider', () => { expect(callback).toHaveBeenCalledWith(events) }) + it('fans out same-root watch events and unwatches only after the last subscriber', async () => { + const first = vi.fn() + const second = vi.fn() + const unsubFirst = await provider.watch('/home/user/project', first) + const unsubSecond = await provider.watch('/home/user/project', second) + + expect(mux.request).toHaveBeenCalledTimes(1) + expect(mux.request).toHaveBeenCalledWith('fs.watch', { rootPath: '/home/user/project' }) + + const notifHandler = mux.onNotification.mock.calls[0][0] + const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + notifHandler('fs.changed', { events }) + expect(first).toHaveBeenCalledWith(events) + expect(second).toHaveBeenCalledWith(events) + + unsubFirst() + expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + notifHandler('fs.changed', { events }) + expect(second).toHaveBeenCalledTimes(2) + + unsubSecond() + expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + }) + + it('shares an in-flight same-root watch setup across concurrent subscribers', async () => { + let resolveWatch: () => void = () => {} + mux.request.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWatch = resolve + }) + ) + const first = vi.fn() + const second = vi.fn() + + const firstWatch = provider.watch('/home/user/project', first) + const secondWatch = provider.watch('/home/user/project', second) + + expect(mux.request).toHaveBeenCalledTimes(1) + resolveWatch() + const [unsubFirst, unsubSecond] = await Promise.all([firstWatch, secondWatch]) + + const notifHandler = mux.onNotification.mock.calls[0][0] + const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + notifHandler('fs.changed', { events }) + expect(first).toHaveBeenCalledWith(events) + expect(second).toHaveBeenCalledWith(events) + + unsubFirst() + expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + unsubSecond() + expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + }) + + it('does not retain a watch listener when fs.watch setup fails', async () => { + mux.request.mockRejectedValueOnce(new Error('watch unavailable')) + const first = vi.fn() + await expect(provider.watch('/home/user/project', first)).rejects.toThrow('watch unavailable') + + const second = vi.fn() + await provider.watch('/home/user/project', second) + + const notifHandler = mux.onNotification.mock.calls[0][0] + const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + notifHandler('fs.changed', { events }) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledWith(events) + }) + + it('does not forward sibling paths with matching prefixes', async () => { + const callback = vi.fn() + await provider.watch('/home/user/project', callback) + + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('fs.changed', { + events: [ + { kind: 'update', absolutePath: '/home/user/project-old/file.ts' }, + { kind: 'update', absolutePath: '/home/user/project2/file.ts' } + ] + }) + + expect(callback).not.toHaveBeenCalled() + }) + + it('matches Windows and UNC watch roots case-insensitively', async () => { + const driveCallback = vi.fn() + const uncCallback = vi.fn() + await provider.watch('C:\\Repo', driveCallback) + await provider.watch('//Server/Share/Repo', uncCallback) + + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('fs.changed', { + events: [ + { kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' }, + { kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' } + ] + }) + + expect(driveCallback).toHaveBeenCalledWith([ + { kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' } + ]) + expect(uncCallback).toHaveBeenCalledWith([ + { kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' } + ]) + }) + it('sends fs.unwatch when last listener unsubscribes', async () => { const callback = vi.fn() const unsub = await provider.watch('/home/user/project', callback) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index d0c545d6881..b33c0dbe08b 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -1,7 +1,16 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' +import { uploadBuffer } from '../ssh/sftp-upload' import type { IFilesystemProvider, FileStat, FileReadResult } from './types' import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import type { SFTPWrapper } from 'ssh2' + +type SftpFactory = () => Promise +type WatchRegistration = { + callbacks: Set<(events: FsChangeEvent[]) => void> + setupPromise: Promise +} export class SshFilesystemProvider implements IFilesystemProvider { private connectionId: string @@ -9,7 +18,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { // Why: each watch() call registers for a specific rootPath, but the relay // sends all fs.changed events on one notification channel. Keying by rootPath // prevents cross-pollination between different worktree watchers. - private watchListeners = new Map void>() + private watchListeners = new Map() // Why: store the unsubscribe handle so dispose() can detach from the // multiplexer. Without this, notification callbacks keep firing after // the provider is torn down on disconnect, routing events to stale state. @@ -19,17 +28,23 @@ export class SshFilesystemProvider implements IFilesystemProvider { // relays get diagnosed quickly without per-read log spam. private loggedStreamFallback = false - constructor(connectionId: string, mux: SshChannelMultiplexer) { + constructor( + connectionId: string, + mux: SshChannelMultiplexer, + private readonly createSftp?: SftpFactory + ) { this.connectionId = connectionId this.mux = mux this.unsubscribeNotifications = mux.onNotification((method, params) => { if (method === 'fs.changed') { const events = params.events as FsChangeEvent[] - for (const [rootPath, cb] of this.watchListeners) { - const matching = events.filter((e) => e.absolutePath.startsWith(rootPath)) + for (const [rootPath, registration] of this.watchListeners) { + const matching = events.filter((e) => isPathInsideOrEqual(rootPath, e.absolutePath)) if (matching.length > 0) { - cb(matching) + for (const cb of registration.callbacks) { + cb(matching) + } } } } @@ -78,6 +93,31 @@ export class SshFilesystemProvider implements IFilesystemProvider { await this.mux.request('fs.writeFile', { filePath, content }) } + async writeFileBase64(filePath: string, contentBase64: string): Promise { + await this.writeFileBase64Chunk(filePath, contentBase64, false) + } + + async writeFileBase64Chunk( + filePath: string, + contentBase64: string, + append: boolean + ): Promise { + if (!this.createSftp) { + throw new Error('remote_binary_upload_unavailable') + } + const sftp = await this.createSftp() + try { + // Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes + // directly so runtime uploads do not corrupt images, PDFs, or archives. + await uploadBuffer(sftp, Buffer.from(contentBase64, 'base64'), filePath, { + append, + exclusive: !append + }) + } finally { + sftp.end() + } + } + async stat(filePath: string): Promise { return (await this.mux.request('fs.stat', { filePath })) as FileStat } @@ -94,6 +134,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { await this.mux.request('fs.createDir', { dirPath }) } + async createDirNoClobber(dirPath: string): Promise { + await this.mux.request('fs.createDirNoClobber', { dirPath }) + } + async rename(oldPath: string, newPath: string): Promise { await this.mux.request('fs.rename', { oldPath, newPath }) } @@ -122,15 +166,41 @@ export class SshFilesystemProvider implements IFilesystemProvider { } async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> { - this.watchListeners.set(rootPath, callback) - await this.mux.request('fs.watch', { rootPath }) + let registration = this.watchListeners.get(rootPath) + if (registration) { + registration.callbacks.add(callback) + await registration.setupPromise + return this.createWatchUnsubscribe(rootPath, registration, callback) + } + const callbacks = new Set<(events: FsChangeEvent[]) => void>([callback]) + const setupPromise = this.mux.request('fs.watch', { rootPath }).then( + () => undefined, + (error) => { + if (this.watchListeners.get(rootPath) === registration) { + this.watchListeners.delete(rootPath) + } + throw error + } + ) + registration = { callbacks, setupPromise } + this.watchListeners.set(rootPath, registration) + await setupPromise + + return this.createWatchUnsubscribe(rootPath, registration, callback) + } + + private createWatchUnsubscribe( + rootPath: string, + registration: WatchRegistration, + callback: (events: FsChangeEvent[]) => void + ): () => void { return () => { - this.watchListeners.delete(rootPath) - // Why: each watch() starts a @parcel/watcher on the relay for this specific - // rootPath. We must always notify the relay to stop it, not only when all - // watchers are gone — otherwise the remote watcher leaks inotify descriptors. - this.mux.notify('fs.unwatch', { rootPath }) + registration.callbacks.delete(callback) + if (registration.callbacks.size === 0 && this.watchListeners.get(rootPath) === registration) { + this.watchListeners.delete(rootPath) + this.mux.notify('fs.unwatch', { rootPath }) + } } } } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 024026115eb..998c22b3f73 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -118,10 +118,13 @@ export type IFilesystemProvider = { readDir(dirPath: string): Promise readFile(filePath: string): Promise writeFile(filePath: string, content: string): Promise + writeFileBase64(filePath: string, contentBase64: string): Promise + writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise stat(filePath: string): Promise deletePath(targetPath: string, recursive?: boolean): Promise createFile(filePath: string): Promise createDir(dirPath: string): Promise + createDirNoClobber(dirPath: string): Promise rename(oldPath: string, newPath: string): Promise copy(source: string, destination: string): Promise realpath(filePath: string): Promise diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 0c18d639340..e04fd6ca5b4 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -41,8 +41,11 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(opencodeRestoreIndex).toBeGreaterThan(outputEncodingIndex) expect(piRestoreIndex).toBeGreaterThan(outputEncodingIndex) expect(promptIndex).toBeGreaterThan(piRestoreIndex) - expect(command).toContain('`e]133;D;$fakeExitCode`a') - expect(command).toContain('`e]133;C`a') + expect(command).toContain('Esc = [char]27') + expect(command).toContain('Bel = [char]7') + expect(command).toContain(')]133;D;$fakeExitCode$(') + expect(command).toContain(')]133;C$(') + expect(command).not.toContain('`e]133') }) it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => { diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 5ccb91123ba..2469c4810df 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,6 +1,7 @@ import type { GitWorktreeInfo, Repo } from '../shared/types' import { listWorktrees } from './git/worktree' import { isFolderRepo } from '../shared/repo-kind' +import { getSshGitProvider } from './providers/ssh-git-dispatch' export function createFolderWorktree(repo: Repo): GitWorktreeInfo { return { @@ -19,5 +20,12 @@ export async function listRepoWorktrees(repo: Repo): Promise if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + // Why: runtime worktree resolution can run before SSH providers have + // reattached during startup. Return empty instead of falling back to + // local git against a server path. + return provider ? await provider.listWorktrees(repo.path) : [] + } return await listWorktrees(repo.path) } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index c925bce2b9c..5a03d05a1c1 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -3,15 +3,19 @@ // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. import { randomBytes, randomUUID } from 'crypto' -import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { existsSync, readFileSync } from 'fs' import { join } from 'path' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' const DEVICE_REGISTRY_FILENAME = 'orca-devices.json' +export type DeviceScope = 'mobile' | 'runtime' + export type DeviceEntry = { deviceId: string name: string token: string + scope: DeviceScope pairedAt: number lastSeenAt: number } @@ -25,11 +29,12 @@ export class DeviceRegistry { this.load() } - addDevice(name: string): DeviceEntry { + addDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { const entry: DeviceEntry = { deviceId: randomUUID(), name, token: randomBytes(24).toString('hex'), + scope, pairedAt: Date.now(), lastSeenAt: 0 } @@ -44,12 +49,12 @@ export class DeviceRegistry { // copy-button flow that encourages regeneration) leaves an orphaned token // forever. Returns an existing never-scanned entry if present; otherwise // mints a new one and drops any stale pending entries. - getOrCreatePendingDevice(name: string): DeviceEntry { - const existing = this.devices.find((d) => d.lastSeenAt === 0) + getOrCreatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { + const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope) if (existing) { return existing } - return this.addDevice(name) + return this.addDevice(name, scope) } // Why: explicit rotation path for "Regenerate QR" — invalidates any @@ -58,9 +63,9 @@ export class DeviceRegistry { // this, getOrCreatePendingDevice keeps returning the same token forever // until a phone actually pairs, so users have no way to revoke a leaked // pre-pairing token. - rotatePendingDevice(name: string): DeviceEntry { - this.devices = this.devices.filter((d) => d.lastSeenAt !== 0) - return this.addDevice(name) + rotatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { + this.devices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope) + return this.addDevice(name, scope) } removeDevice(deviceId: string): boolean { @@ -73,6 +78,10 @@ export class DeviceRegistry { return false } + getDevice(deviceId: string): DeviceEntry | null { + return this.devices.find((d) => d.deviceId === deviceId) ?? null + } + listDevices(): readonly DeviceEntry[] { return this.devices } @@ -95,14 +104,20 @@ export class DeviceRegistry { return } try { - this.devices = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] + hardenExistingSecureFile(this.registryPath) + const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] + this.devices = parsed.map((device) => ({ + ...device, + // Why: older registries only existed for phone pairing. Treat missing + // scope as mobile so legacy device tokens do not gain new CLI powers. + scope: device.scope === 'runtime' ? 'runtime' : 'mobile' + })) } catch { this.devices = [] } } private save(): void { - writeFileSync(this.registryPath, JSON.stringify(this.devices, null, 2), { mode: 0o600 }) - chmodSync(this.registryPath, 0o600) + writeSecureJsonFile(this.registryPath, this.devices) } } diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts index de0373ebafe..eee61363191 100644 --- a/src/main/runtime/e2ee-keypair.ts +++ b/src/main/runtime/e2ee-keypair.ts @@ -1,9 +1,10 @@ // Why: the E2EE keypair enables application-layer encryption between mobile // and desktop over plain ws://. The public key is embedded in the QR pairing // offer so the mobile client can derive a shared secret via ECDH. -import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { existsSync, readFileSync } from 'fs' import { join } from 'path' import nacl from 'tweetnacl' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json' const KEYPAIR_VERSION = 1 @@ -25,6 +26,7 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { if (existsSync(filePath)) { try { + hardenExistingSecureFile(filePath) const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8')) if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) { const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64')) @@ -43,8 +45,7 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64') const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 } - writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8') - chmodSync(filePath, 0o600) + writeSecureJsonFile(filePath, data) return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 } } diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index d3a84349f2c..e25fbe22674 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -295,6 +295,27 @@ describe('mobile presence lock — multi-mobile semantics', () => { expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true) }) + it('updateDesktopViewport resizes the source PTY and records desktop geometry', async () => { + const { runtime, ptySizes, resizes } = createRuntime() + + expect(await runtime.updateDesktopViewport('pty-1', { cols: 132, rows: 44 })).toBe(true) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 132, rows: 44 }) + expect(resizes.at(-1)).toEqual({ ptyId: 'pty-1', cols: 132, rows: 44 }) + expect(runtime.getLastRendererSize('pty-1')).toEqual({ cols: 132, rows: 44 }) + }) + + it('updateDesktopViewport does not resize while mobile is driving', async () => { + const { runtime, ptySizes, resizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 20 }) + resizes.length = 0 + + expect(await runtime.updateDesktopViewport('pty-1', { cols: 132, rows: 44 })).toBe(false) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 20 }) + expect(resizes).toEqual([]) + }) + it('updateMobileViewport then disconnect restores PTY to original baseline', async () => { const { runtime, ptySizes } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts new file mode 100644 index 00000000000..ea66e39da40 --- /dev/null +++ b/src/main/runtime/orca-runtime-browser.ts @@ -0,0 +1,1455 @@ +/* eslint-disable max-lines -- Why: this file is a command adapter for one external surface, Agent Browser automation. It stays separate from OrcaRuntimeService so runtime state does not grow further while browser routing remains easy to scan in one place. */ +import { randomUUID } from 'crypto' +import { ipcMain, type BrowserWindow } from 'electron' +import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import type { + BrowserBackResult, + BrowserCaptureStartResult, + BrowserCheckResult, + BrowserCaptureStopResult, + BrowserClearResult, + BrowserClickResult, + BrowserConsoleResult, + BrowserCookieDeleteResult, + BrowserCookieGetResult, + BrowserCookieSetResult, + BrowserDetectProfilesResult, + BrowserDragResult, + BrowserEvalResult, + BrowserFillResult, + BrowserFocusResult, + BrowserGeolocationResult, + BrowserGotoResult, + BrowserHoverResult, + BrowserInterceptDisableResult, + BrowserInterceptEnableResult, + BrowserKeypressResult, + BrowserNetworkLogResult, + BrowserPdfResult, + BrowserProfileClearDefaultCookiesResult, + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileImportFromBrowserResult, + BrowserProfileListResult, + BrowserReloadResult, + BrowserScreenshotResult, + BrowserScrollResult, + BrowserSelectAllResult, + BrowserSelectResult, + BrowserSnapshotResult, + BrowserTabCurrentResult, + BrowserTabListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabSetProfileResult, + BrowserTabShowResult, + BrowserTabSwitchResult, + BrowserTypeResult, + BrowserUploadResult, + BrowserViewportResult, + BrowserWaitResult +} from '../../shared/runtime-types' +import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { browserManager } from '../browser/browser-manager' +import { BrowserError } from '../browser/cdp-bridge' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { + detectInstalledBrowsers, + importCookiesFromBrowser, + selectBrowserProfile +} from '../browser/browser-cookie-import' +import { waitForTabRegistration } from '../ipc/browser' + +export type BrowserCommandTargetParams = { + worktree?: string + page?: string +} + +type ResolvedBrowserCommandTarget = { + worktreeId?: string + browserPageId?: string +} + +export type RuntimeBrowserCommandHost = { + getAgentBrowserBridge(): AgentBrowserBridge | null + resolveWorktreeSelector(selector: string): Promise<{ id: string }> + getAuthoritativeWindow(): BrowserWindow + getAvailableAuthoritativeWindow(): BrowserWindow | null +} + +export class RuntimeBrowserCommands { + constructor(private readonly host: RuntimeBrowserCommandHost) {} + + private requireAgentBrowserBridge(): AgentBrowserBridge { + const bridge = this.host.getAgentBrowserBridge() + if (!bridge) { + throw new BrowserError('browser_no_tab', 'No browser session is active') + } + return bridge + } + + // Why: the CLI sends worktree selectors (e.g. "path:/Users/...") but the + // bridge stores worktreeIds in "repoId::path" format (from the renderer's + // Zustand store). This helper resolves the selector to the store-compatible + // ID so the bridge can filter tabs correctly. + private async resolveBrowserWorktreeId(selector?: string): Promise { + if (!selector) { + // Why: after app restart, webviews only mount when the browser pane is visible. + // Without --worktree, we still need to activate the view so persisted tabs + // become operable via registerGuest. + const bridge = this.host.getAgentBrowserBridge() + if (bridge && bridge.getRegisteredTabs().size === 0) { + try { + const win = this.host.getAuthoritativeWindow() + win.webContents.send('browser:activateView', {}) + await new Promise((resolve) => setTimeout(resolve, 500)) + } catch { + // Window may not exist yet (e.g. during startup or in tests) + } + } + return undefined + } + + const worktreeId = (await this.host.resolveWorktreeSelector(selector)).id + // Why: explicit worktree selectors are user intent, so resolution errors + // must surface instead of silently widening browser routing scope. Only the + // activation step remains best-effort because missing windows during tests + // or startup should not erase the validated worktree target itself. + const bridge = this.host.getAgentBrowserBridge() + if (bridge && bridge.getRegisteredTabs(worktreeId).size === 0) { + try { + await this.ensureBrowserWorktreeActive(worktreeId) + } catch { + // Fall through with the validated worktree id so downstream routing + // still stays scoped to the caller's explicit selector. + } + } + return worktreeId + } + + private async resolveBrowserCommandTarget( + params: BrowserCommandTargetParams + ): Promise { + const browserPageId = + typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined + if (!browserPageId) { + return { + worktreeId: await this.resolveBrowserWorktreeId(params.worktree) + } + } + + return { + // Why: explicit browserPageId is already a stable tab identity, so we do + // not auto-resolve cwd worktree scoping on top of it. Only honor an + // explicit --worktree when the caller asked for that extra validation. + worktreeId: params.worktree + ? await this.resolveBrowserWorktreeId(params.worktree) + : undefined, + browserPageId + } + } + + // Why: browser tabs only mount (and become operable) when their worktree is + // the active worktree in the renderer AND activeTabType is 'browser'. If either + // condition is false, the webview stays in display:none and Electron won't start + // its guest process — dom-ready never fires, registerGuest never runs, and CLI + // browser commands fail with "CDP connection refused". + private async ensureBrowserWorktreeActive(worktreeId: string): Promise { + const win = this.host.getAuthoritativeWindow() + const repoId = getRepoIdFromWorktreeId(worktreeId) + if (!repoId) { + return + } + win.webContents.send('ui:activateWorktree', { repoId, worktreeId }) + // Why: switching worktree alone sets activeView='terminal'. Browser webviews + // won't mount until activeTabType is 'browser'. Send a second IPC to flip it. + win.webContents.send('browser:activateView', { worktreeId }) + // Why: give the renderer time to mount the webview after switching worktrees. + // The webview needs to attach and fire dom-ready before registerGuest runs. + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + // Why: agent-browser drives navigation via CDP, which bypasses Electron's + // webview event system. The renderer's did-navigate / page-title-updated + // listeners never fire, leaving the Zustand store (and thus the Orca UI's + // address bar and tab title) stale. Push updates from main → renderer after + // any navigation-causing command so the UI stays in sync. + private notifyRendererNavigation(browserPageId: string, url: string, title: string): void { + try { + const win = this.host.getAuthoritativeWindow() + win.webContents.send('browser:navigation-update', { browserPageId, url, title }) + } catch { + // Window may not exist during shutdown + } + } + + // Why: `tabSwitch` only flips the bridge's `activeWebContentsId` — it + // does not surface the browser pane in the renderer. Without --focus, the + // switch is invisible to the user. With --focus, we send a dedicated IPC + // so the renderer can update its per-worktree active-tab state. + // + // Why this IPC carries `worktreeId` instead of letting the renderer + // dispatch `setActiveWorktree`: multiple agents drive browsers in parallel + // worktrees. A global focus call from agent X would steal the user's + // screen from agent Y's worktree. The renderer-side handler + // (focusBrowserTabInWorktree) updates per-worktree state unconditionally + // and only flips globals when the user is already on the targeted + // worktree. Cross-worktree --focus calls pre-stage silently. + private notifyRendererBrowserPaneFocus( + worktreeId: string | undefined, + browserPageId: string + ): void { + try { + const win = this.host.getAuthoritativeWindow() + win.webContents.send('browser:pane-focus', { + worktreeId: worktreeId ?? null, + browserPageId + }) + } catch { + // Window may not exist during shutdown + } + } + + async browserSnapshot(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().snapshot(target.worktreeId, target.browserPageId) + } + + async browserClick( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.click(params.element, target.worktreeId, target.browserPageId) + // Why: clicks can trigger navigation (e.g. submitting a form, clicking a link). + // Read the target tab's live URL/title after the click and push to the + // renderer so the UI updates even when automation targeted a non-active page. + const page = bridge.getPageInfo(target.worktreeId, target.browserPageId) + if (page) { + this.notifyRendererNavigation(page.browserPageId, page.url, page.title) + } + return result + } + + async browserGoto( + params: { url: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.notifyRendererNavigation(pageId, result.url, result.title) + } + return result + } + + async browserFill( + params: { + element: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().fill( + params.element, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserType( + params: { input: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().type( + params.input, + target.worktreeId, + target.browserPageId + ) + } + + async browserSelect( + params: { + element: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().select( + params.element, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserScroll( + params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().scroll( + params.direction, + params.amount, + target.worktreeId, + target.browserPageId + ) + } + + async browserBack(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.back(target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.notifyRendererNavigation(pageId, result.url, result.title) + } + return result + } + + async browserReload(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.reload(target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.notifyRendererNavigation(pageId, result.url, result.title) + } + return result + } + + async browserScreenshot( + params: { + format?: 'png' | 'jpeg' + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().screenshot( + params.format, + target.worktreeId, + target.browserPageId + ) + } + + async browserEval( + params: { expression: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().evaluate( + params.expression, + target.worktreeId, + target.browserPageId + ) + } + + async browserTabList(params: { worktree?: string }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + const result = this.requireAgentBrowserBridge().tabList(worktreeId) + return { + tabs: result.tabs.map((tab) => this.enrichBrowserTabInfo(tab)) + } + } + + async browserTabShow(params: { page: string; worktree?: string }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + return { tab: this.describeBrowserTab(params.page, worktreeId) } + } + + async browserTabCurrent(params: { worktree?: string }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + const browserPageId = this.requireAgentBrowserBridge().getActivePageId(worktreeId) + if (!browserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + return { tab: this.describeBrowserTab(browserPageId, worktreeId) } + } + + async browserTabSwitch( + params: { + index?: number + focus?: boolean + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.tabSwitch(params.index, target.worktreeId, target.browserPageId) + if (params.focus) { + // Why: prefer the explicit --worktree the caller passed; fall back to + // the bridge's owning-worktree map for the just-switched tab. The + // owning worktree is what the renderer needs to scope the focus to. + // The renderer NEVER yanks the user across worktrees on this signal + // (see focusBrowserTabInWorktree). + const worktreeId = + target.worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined + this.notifyRendererBrowserPaneFocus(worktreeId, result.browserPageId) + } + return result + } + + async browserHover( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().hover( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserDrag( + params: { + from: string + to: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().drag( + params.from, + params.to, + target.worktreeId, + target.browserPageId + ) + } + + async browserUpload( + params: { element: string; files: string[] } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().upload( + params.element, + params.files, + target.worktreeId, + target.browserPageId + ) + } + + async browserWait( + params: { + selector?: string + timeout?: number + text?: string + url?: string + load?: string + fn?: string + state?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const { worktree: _, page: __, ...options } = params + return this.requireAgentBrowserBridge().wait(options, target.worktreeId, target.browserPageId) + } + + async browserCheck( + params: { element: string; checked: boolean } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().check( + params.element, + params.checked, + target.worktreeId, + target.browserPageId + ) + } + + async browserFocus( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().focus( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserClear( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().clear( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserSelectAll( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().selectAll( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserKeypress( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().keypress( + params.key, + target.worktreeId, + target.browserPageId + ) + } + + async browserPdf(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId) + } + + async browserFullScreenshot( + params: { + format?: 'png' | 'jpeg' + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().fullPageScreenshot( + params.format, + target.worktreeId, + target.browserPageId + ) + } + + // ── Cookie management ── + + async browserCookieGet( + params: { url?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().cookieGet( + params.url, + target.worktreeId, + target.browserPageId + ) + } + + async browserCookieSet( + params: { + name: string + value: string + domain?: string + path?: string + secure?: boolean + httpOnly?: boolean + sameSite?: string + expires?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().cookieSet( + params, + target.worktreeId, + target.browserPageId + ) + } + + async browserCookieDelete( + params: { + name: string + domain?: string + url?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().cookieDelete( + params.name, + params.domain, + params.url, + target.worktreeId, + target.browserPageId + ) + } + + // ── Viewport ── + + async browserSetViewport( + params: { + width: number + height: number + deviceScaleFactor?: number + mobile?: boolean + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setViewport( + params.width, + params.height, + params.deviceScaleFactor, + params.mobile, + target.worktreeId, + target.browserPageId + ) + } + + // ── Geolocation ── + + async browserSetGeolocation( + params: { + latitude: number + longitude: number + accuracy?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setGeolocation( + params.latitude, + params.longitude, + params.accuracy, + target.worktreeId, + target.browserPageId + ) + } + + // ── Request interception ── + + async browserInterceptEnable( + params: { + patterns?: string[] + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().interceptEnable( + params.patterns, + target.worktreeId, + target.browserPageId + ) + } + + async browserInterceptDisable( + params: BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().interceptDisable( + target.worktreeId, + target.browserPageId + ) + } + + async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().interceptList(target.worktreeId, target.browserPageId) + } + + // ── Console/network capture ── + + async browserCaptureStart( + params: BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId) + } + + async browserCaptureStop(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId) + } + + async browserConsoleLog( + params: { limit?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().consoleLog( + params.limit, + target.worktreeId, + target.browserPageId + ) + } + + async browserNetworkLog( + params: { limit?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().networkLog( + params.limit, + target.worktreeId, + target.browserPageId + ) + } + + // ── Additional core commands ── + + async browserDblclick( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().dblclick( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserForward(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId) + } + + async browserScrollIntoView( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().scrollIntoView( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserGet( + params: { + what: string + selector?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().get( + params.what, + params.selector, + target.worktreeId, + target.browserPageId + ) + } + + async browserIs( + params: { what: string; selector: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().is( + params.what, + params.selector, + target.worktreeId, + target.browserPageId + ) + } + + // ── Keyboard insert text ── + + async browserKeyboardInsertText( + params: { text: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().keyboardInsertText( + params.text, + target.worktreeId, + target.browserPageId + ) + } + + // ── Mouse commands ── + + async browserMouseMove( + params: { x: number; y: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseMove( + params.x, + params.y, + target.worktreeId, + target.browserPageId + ) + } + + async browserMouseDown( + params: { button?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseDown( + params.button, + target.worktreeId, + target.browserPageId + ) + } + + async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseUp( + params.button, + target.worktreeId, + target.browserPageId + ) + } + + async browserMouseWheel( + params: { + dy: number + dx?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseWheel( + params.dy, + params.dx, + target.worktreeId, + target.browserPageId + ) + } + + // ── Find (semantic locators) ── + + async browserFind( + params: { + locator: string + value: string + action: string + text?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().find( + params.locator, + params.value, + params.action, + params.text, + target.worktreeId, + target.browserPageId + ) + } + + // ── Set commands ── + + async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setDevice( + params.name, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetOffline( + params: { state?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setOffline( + params.state, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetHeaders( + params: { headers: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setHeaders( + params.headers, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetCredentials( + params: { + user: string + pass: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setCredentials( + params.user, + params.pass, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetMedia( + params: { + colorScheme?: string + reducedMotion?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setMedia( + params.colorScheme, + params.reducedMotion, + target.worktreeId, + target.browserPageId + ) + } + + // ── Clipboard commands ── + + async browserClipboardRead(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId) + } + + async browserClipboardWrite( + params: { text: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().clipboardWrite( + params.text, + target.worktreeId, + target.browserPageId + ) + } + + // ── Dialog commands ── + + async browserDialogAccept( + params: { text?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().dialogAccept( + params.text, + target.worktreeId, + target.browserPageId + ) + } + + async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId) + } + + // ── Storage commands ── + + async browserStorageLocalGet( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageLocalGet( + params.key, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageLocalSet( + params: { + key: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageLocalSet( + params.key, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageLocalClear( + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageSessionGet( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageSessionGet( + params.key, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageSessionSet( + params: { + key: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageSessionSet( + params.key, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageSessionClear( + target.worktreeId, + target.browserPageId + ) + } + + // ── Download command ── + + async browserDownload( + params: { + selector: string + path: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().download( + params.selector, + params.path, + target.worktreeId, + target.browserPageId + ) + } + + // ── Highlight command ── + + async browserHighlight( + params: { selector: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().highlight( + params.selector, + target.worktreeId, + target.browserPageId + ) + } + + // ── New: exec passthrough + tab lifecycle ── + + async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().exec( + params.command, + target.worktreeId, + target.browserPageId + ) + } + + async browserTabCreate(params: { + url?: string + worktree?: string + profileId?: string + }): Promise<{ browserPageId: string }> { + const url = params.url ?? 'about:blank' + const worktreeId = params.worktree + ? (await this.host.resolveWorktreeSelector(params.worktree)).id + : undefined + if (!this.host.getAvailableAuthoritativeWindow()) { + throw new BrowserError( + 'browser_error', + 'Browser tab creation requires a desktop renderer; headless orca serve does not support browser panes yet.' + ) + } + const { browserPageId } = await this.createBrowserTabInRenderer( + url, + worktreeId, + params.profileId + ) + + // Why: the renderer creates the Zustand tab immediately, but the webview must + // mount and fire dom-ready before registerGuest runs. Waiting here ensures the + // tab is operable by subsequent CLI commands (snapshot, click, etc.). + // If registration doesn't complete within timeout, return the ID anyway — the + // tab exists in the UI but may not be ready for automation commands yet. + try { + await waitForTabRegistration(browserPageId) + } catch { + // Tab was created in the renderer but the webview hasn't finished mounting. + // Return success since the tab exists; subsequent commands will fail with a + // clear "tab not available" error if the webview never loads. + } + + // Why: newly created tabs should be auto-activated so subsequent commands + // (snapshot, click, goto) target the new tab without requiring an explicit + // tab switch. Without this, the bridge's active tab still points at the + // previously active tab and the new tab shows active: false in tab list. + const bridge = this.requireAgentBrowserBridge() + const wcId = bridge.getRegisteredTabs(worktreeId).get(browserPageId) + if (wcId != null) { + bridge.setActiveTab(wcId, worktreeId) + } + + // Why: the renderer sets webview.src=url on mount, but agent-browser connects + // via CDP after the webview loads about:blank. Without an explicit goto, the + // page stays blank from agent-browser's perspective. Navigate via the bridge + // so agent-browser's CDP session tracks the correct page state. + if (url && url !== 'about:blank') { + try { + const result = await bridge.goto(url, worktreeId, browserPageId) + this.notifyRendererNavigation(browserPageId, result.url, result.title) + } catch { + // Tab exists but navigation failed — caller can retry with explicit goto + } + } + + return { browserPageId } + } + + async browserTabSetProfile( + params: { + profileId: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const browserPageId = + target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + if (!browserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + // Why: 'default' is a synthetic id; fall back to the registry's default profile when not registered. + const profile = + browserSessionRegistry.getProfile(params.profileId) ?? + (params.profileId === 'default' ? browserSessionRegistry.getDefaultProfile() : null) + if (!profile) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + + // Why: short-circuit no-op switches so the renderer doesn't tear down and + // remount the webview when the tab is already on the requested profile. + const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default' + if (currentProfileId === profile.id) { + return { + browserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + const win = this.host.getAuthoritativeWindow() + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabSetProfileReply', handler) + reject(new Error('Tab profile update timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabSetProfileReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve() + } + } + ipcMain.on('browser:tabSetProfileReply', handler) + win.webContents.send('browser:requestTabSetProfile', { + requestId, + browserPageId, + profileId: profile.id + }) + }) + + // Why: the renderer destroys the old webview and remounts on the new + // partition. Wait for the re-register so a follow-up tab list + // --show-profile reads the updated sessionProfileId from BrowserManager + // instead of stale data, and so subsequent CLI ops (snapshot, click, etc.) + // hit a guest that's already attached. + try { + await waitForTabRegistration(browserPageId) + } catch { + // Best-effort: re-register won't fire if the worktree is hidden. The + // store already reflects the new profile; downstream commands retry + // once the pane re-mounts. + } + + return { + browserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + async browserTabProfileShow(params: { + page: string + worktree?: string + }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + const tab = this.describeBrowserTab(params.page, worktreeId) + return { + browserPageId: tab.browserPageId, + worktreeId: tab.worktreeId ?? null, + profileId: tab.profileId ?? null, + profileLabel: tab.profileLabel ?? null + } + } + + async browserTabProfileClone( + params: { + profileId: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const sourceBrowserPageId = + target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + if (!sourceBrowserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId) + const profile = browserSessionRegistry.getProfile(params.profileId) + if (!profile) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + const created = await this.createBrowserTabInRenderer( + sourceTab.url, + sourceTab.worktreeId ?? target.worktreeId, + profile.id + ) + // Why: parity with browserTabCreate. Wait for the cloned tab's webview to + // register so the returned browserPageId is operable by the next CLI call. + try { + await waitForTabRegistration(created.browserPageId) + } catch { + // Best-effort: registration may not fire if the worktree is hidden. + } + return { + browserPageId: created.browserPageId, + sourceBrowserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + async browserProfileList(): Promise { + return { profiles: browserSessionRegistry.listProfiles() } + } + + async browserProfileCreate(params: { + label: string + scope: 'isolated' | 'imported' + }): Promise { + return { + profile: browserSessionRegistry.createProfile(params.scope, params.label) + } + } + + async browserProfileDelete(params: { profileId: string }): Promise { + return { + deleted: await browserSessionRegistry.deleteProfile(params.profileId), + profileId: params.profileId + } + } + + async browserProfileDetectBrowsers(): Promise { + return { + // Why: clients only need display metadata for the picker; filesystem + // paths and keychain identifiers stay on the runtime server. + browsers: detectInstalledBrowsers().map((browser) => ({ + family: browser.family, + label: browser.label, + profiles: browser.profiles, + selectedProfile: browser.selectedProfile + })) + } + } + + async browserProfileImportFromBrowser(params: { + profileId: string + browserFamily: string + browserProfile?: string + }): Promise { + const profile = browserSessionRegistry.getProfile(params.profileId) + if (!profile) { + return { ok: false, reason: 'Session profile not found.' } + } + if ( + params.browserProfile && + (/[/\\]/.test(params.browserProfile) || params.browserProfile.includes('..')) + ) { + return { ok: false, reason: 'Invalid browser profile name.' } + } + + const browsers = detectInstalledBrowsers() + let browser = browsers.find((candidate) => candidate.family === params.browserFamily) + if (!browser) { + return { ok: false, reason: 'Browser not found on this system.' } + } + + if (params.browserProfile && params.browserProfile !== browser.selectedProfile) { + const reselected = selectBrowserProfile(browser, params.browserProfile) + if (!reselected) { + return { + ok: false, + reason: `No cookies database found for profile "${params.browserProfile}".` + } + } + browser = reselected + } + + const result = await importCookiesFromBrowser(browser, profile.partition) + if (!result.ok) { + return result + } + + const profileName = + browser.profiles.find((candidate) => candidate.directory === browser.selectedProfile)?.name ?? + browser.selectedProfile + browserSessionRegistry.updateProfileSource(params.profileId, { + browserFamily: browser.family, + profileName, + importedAt: Date.now() + }) + return { ...result, profileId: params.profileId } + } + + async browserProfileClearDefaultCookies(): Promise { + return { cleared: await browserSessionRegistry.clearDefaultSessionCookies() } + } + + async browserTabClose(params: { + index?: number + page?: string + worktree?: string + }): Promise<{ closed: boolean }> { + const bridge = this.requireAgentBrowserBridge() + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + + let tabId: string | null = null + if (typeof params.page === 'string' && params.page.length > 0) { + if (!bridge.getRegisteredTabs(worktreeId).has(params.page)) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${params.page} was not found${scope}` + ) + } + tabId = params.page + } else if (params.index !== undefined) { + const tabs = bridge.getRegisteredTabs(worktreeId) + const entries = [...tabs.entries()] + if (params.index < 0 || params.index >= entries.length) { + throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`) + } + tabId = entries[params.index][0] + } else { + // Why: try the bridge first (registered tabs with webviews), then fall back + // to asking the renderer to close its active browser tab (handles cases where + // the webview hasn't mounted yet, e.g. tab was just created). + const tabs = bridge.getRegisteredTabs(worktreeId) + const entries = [...tabs.entries()] + const activeEntry = entries.find(([, wcId]) => wcId === bridge.getActiveWebContentsId()) + if (activeEntry) { + tabId = activeEntry[0] + } + } + + const win = this.host.getAuthoritativeWindow() + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabCloseReply', handler) + reject(new Error('Tab close timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabCloseReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve() + } + } + ipcMain.on('browser:tabCloseReply', handler) + // Why: when main cannot resolve a concrete tab id itself (for example if a + // browser workspace exists in the renderer before its guest mounts), the + // renderer still needs the intended worktree scope. Otherwise it falls + // back to the globally active browser tab and can close a tab in the + // wrong worktree. + win.webContents.send('browser:requestTabClose', { requestId, tabId, worktreeId }) + }) + + return { closed: true } + } + + private enrichBrowserTabInfo( + tab: BrowserTabListResult['tabs'][number] + ): BrowserTabListResult['tabs'][number] { + const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) + const profile = + browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? + browserSessionRegistry.getDefaultProfile() + return { + ...tab, + worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, + profileId: profile.id, + profileLabel: profile.label + } + } + + private describeBrowserTab( + browserPageId: string, + explicitWorktreeId?: string + ): BrowserTabListResult['tabs'][number] { + const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId) + const tab = this.requireAgentBrowserBridge() + .tabList(worktreeId) + .tabs.find((entry) => entry.browserPageId === browserPageId) + if (!tab) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} was not found${scope}` + ) + } + return this.enrichBrowserTabInfo(tab) + } + + private async createBrowserTabInRenderer( + url: string, + worktreeId?: string, + profileId?: string + ): Promise<{ browserPageId: string }> { + const win = this.host.getAuthoritativeWindow() + const requestId = randomUUID() + + if (worktreeId) { + await this.ensureBrowserWorktreeActive(worktreeId) + } + + const browserPageId = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabCreateReply', handler) + reject(new Error('Tab creation timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; browserPageId?: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabCreateReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve(reply.browserPageId!) + } + } + ipcMain.on('browser:tabCreateReply', handler) + win.webContents.send('browser:requestTabCreate', { + requestId, + url, + worktreeId, + sessionProfileId: profileId + }) + }) + + return { browserPageId } + } +} diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts new file mode 100644 index 00000000000..e77943d1049 --- /dev/null +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Fs from 'fs' +import type * as FsPromises from 'fs/promises' +import type * as FilesystemAuth from '../ipc/filesystem-auth' + +const { resolveAuthorizedPathMock, statMock, watchMock } = vi.hoisted(() => ({ + resolveAuthorizedPathMock: vi.fn(), + statMock: vi.fn(), + watchMock: vi.fn() +})) + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + watch: watchMock + } +}) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { + ...actual, + stat: statMock + } +}) + +vi.mock('../ipc/filesystem-auth', async () => { + const actual = await vi.importActual('../ipc/filesystem-auth') + return { + ...actual, + resolveAuthorizedPath: resolveAuthorizedPathMock + } +}) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn() +})) + +import { RuntimeFileCommands } from './orca-runtime-files' + +describe('RuntimeFileCommands', () => { + const originalPlatform = process.platform + + beforeEach(() => { + vi.useFakeTimers() + resolveAuthorizedPathMock.mockReset() + statMock.mockReset() + watchMock.mockReset() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + vi.useRealTimers() + }) + + it('uses a conservative Node watcher for Windows runtime file watches', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const store = { getRepo: vi.fn(() => undefined) } + const close = vi.fn() + const on = vi.fn() + let listener: (() => void) | null = null + watchMock.mockImplementation((_rootPath, _options, callback) => { + listener = callback + return { close, on } + }) + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + + const commands = new RuntimeFileCommands({ + getRuntimeId: () => 'runtime-1', + requireStore: () => store, + resolveWorktreeSelector: vi.fn(async () => ({ + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\repo' + })), + resolveRuntimeGitTarget: vi.fn(), + openFile: vi.fn() + } as never) + const onEvents = vi.fn() + + const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents) + + expect(watchMock).toHaveBeenCalledWith('C:\\repo', { recursive: true }, expect.any(Function)) + const emit = listener as (() => void) | null + expect(emit).not.toBeNull() + + emit?.() + emit?.() + await vi.advanceTimersByTimeAsync(149) + expect(onEvents).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(onEvents).toHaveBeenCalledTimes(1) + expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: 'C:\\repo' }]) + + unsubscribe() + expect(close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts new file mode 100644 index 00000000000..ca326ae42ad --- /dev/null +++ b/src/main/runtime/orca-runtime-files.ts @@ -0,0 +1,918 @@ +/* eslint-disable max-lines -- Why: filesystem, editor-file, and search commands share the same local/SSH path authorization rules. Keeping that IO adapter together prevents separate command paths from drifting on safety checks. */ +import type { ChildProcess } from 'child_process' +import { watch as watchFs } from 'fs' +import { + constants, + copyFile, + lstat, + mkdir, + open, + readFile, + readdir, + rename, + rm, + stat, + writeFile +} from 'fs/promises' +import { basename, dirname, extname, join } from 'path' +import type { + DirEntry, + FsChangeEvent, + GitWorktreeInfo, + MarkdownDocument, + SearchOptions, + SearchResult, + Worktree +} from '../../shared/types' +import type { + RuntimeFileListResult, + RuntimeFileOpenResult, + RuntimeFilePreviewResult, + RuntimeFileReadResult +} from '../../shared/runtime-types' +import { wslAwareSpawn } from '../git/runner' +import { parseWslPath, toWindowsWslPath } from '../wsl' +import { isENOENT, resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { listQuickOpenFiles } from '../ipc/filesystem-list-files' +import { searchWithGitGrep } from '../ipc/filesystem-search-git' +import { checkRgAvailable } from '../ipc/rg-availability' +import { + listMarkdownDocuments, + markdownDocumentsFromRelativePaths +} from '../ipc/markdown-documents' +import { + buildRgArgs, + createAccumulator, + DEFAULT_SEARCH_MAX_RESULTS, + finalize, + ingestRgJsonLine, + SEARCH_TIMEOUT_MS +} from '../../shared/text-search' +import type { Store } from '../persistence' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' + +const MOBILE_FILE_LIST_LIMIT = 5000 +const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 +const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 +const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 +const MOBILE_BINARY_EXTENSIONS = new Set([ + '.avif', + '.bmp', + '.gif', + '.heic', + '.ico', + '.jpeg', + '.jpg', + '.mov', + '.mp3', + '.mp4', + '.pdf', + '.png', + '.webp', + '.zip' +]) +const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} + +export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } + +export type RuntimeFileCommandHost = { + getRuntimeId(): string + requireStore(): Store + resolveWorktreeSelector(selector: string): Promise + resolveRuntimeGitTarget( + selector: string + ): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }> + openFile(worktreeId: string, filePath: string, relativePath: string): void +} + +export class RuntimeFileCommands { + private activeRuntimeTextSearches = new Map() + + constructor(private readonly host: RuntimeFileCommandHost) {} + + async listMobileFiles(worktreeSelector: string): Promise { + const store = this.host.requireStore() + const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + const repo = store.getRepo(worktree.repoId) + const connectionId = repo?.connectionId ?? undefined + const files = connectionId + ? await this.listRemoteMobileFiles(worktree.path, connectionId) + : await listQuickOpenFiles(worktree.path, store) + const entries = files + .filter((relativePath) => isSafeMobileRelativePath(relativePath)) + .sort((a, b) => a.localeCompare(b)) + .slice(0, MOBILE_FILE_LIST_LIMIT) + .map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })) + + return { + worktree: worktree.id, + rootPath: worktree.path, + files: entries, + totalCount: files.length, + truncated: files.length > MOBILE_FILE_LIST_LIMIT + } + } + + async openMobileFile( + worktreeSelector: string, + relativePath: string + ): Promise { + const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + if (!isSafeMobileRelativePath(relativePath)) { + throw new Error('invalid_relative_path') + } + const kind = isMobileBinaryPath(relativePath) + ? 'binary' + : isMobileMarkdownPath(relativePath) + ? 'markdown' + : 'text' + if (kind === 'binary') { + return { worktree: worktree.id, relativePath, kind, opened: false } + } + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + this.host.openFile(worktree.id, filePath, relativePath) + return { worktree: worktree.id, relativePath, kind, opened: true } + } + + async readMobileFile( + worktreeSelector: string, + relativePath: string + ): Promise { + const store = this.host.requireStore() + const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + if (!isSafeMobileRelativePath(relativePath)) { + throw new Error('invalid_relative_path') + } + if (isMobileBinaryPath(relativePath)) { + throw new Error('binary_file') + } + + const repo = store.getRepo(worktree.repoId) + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + const content = repo?.connectionId + ? await this.readRemoteMobileFile(filePath, repo.connectionId) + : await readLocalMobileFile(filePath, store) + const truncated = truncateMobileFilePreview(content) + + return { + worktree: worktree.id, + relativePath, + content: truncated.content, + truncated: truncated.truncated, + byteLength: truncated.byteLength + } + } + + async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + return provider.readDir(target.path) + } + + const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = await Promise.all( + entries.map(async (entry) => { + const entryPath = join(dirPath, entry.name) + return { + name: entry.name, + isDirectory: await isRuntimeDirectoryEntry(entryPath), + isSymlink: entry.isSymbolicLink() + } + }) + ) + return mapped.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + } + + async watchFileExplorer( + worktreeSelector: string, + callback: (events: FsChangeEvent[]) => void + ): Promise<() => void> { + const target = await this.resolveFileExplorerPath(worktreeSelector, '') + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + return provider.watch(target.path, callback) + } + + const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const rootStats = await stat(rootPath) + if (!rootStats.isDirectory()) { + throw new Error('not_a_directory') + } + if (process.platform === 'win32') { + return watchWindowsRuntimeFileExplorer(rootPath, callback) + } + const watcher = await import('@parcel/watcher') + const subscription = await watcher.subscribe( + rootPath, + (err, events) => { + if (err) { + console.error('[runtime-files.watch] watcher error', { rootPath, err }) + callback([{ kind: 'overflow', absolutePath: rootPath }]) + return + } + void Promise.all( + events.map(async (event): Promise => { + let isDirectory = false + try { + isDirectory = (await stat(event.path)).isDirectory() + } catch { + isDirectory = false + } + return { + kind: event.type, + absolutePath: event.path, + isDirectory + } + }) + ).then(callback) + }, + { + ignore: [ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.cache', + '__pycache__', + 'target', + '.venv' + ] + } + ) + return () => { + void subscription.unsubscribe().catch((err: unknown) => { + console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) + }) + } + } + + async readFileExplorerPreview( + worktreeSelector: string, + relativePath: string + ): Promise { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + const fileStats = await provider.stat(target.path) + if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) { + throw new Error('file_too_large') + } + const result = await provider.readFile(target.path) + return result + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const fileStats = await stat(filePath) + const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] + if (mimeType) { + if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) { + throw new Error('file_too_large') + } + const buffer = await readFile(filePath) + return { + content: buffer.toString('base64'), + isBinary: true, + isImage: true, + mimeType + } + } + + if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + const buffer = await readFile(filePath) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } + } + + async writeFileExplorerFile( + worktreeSelector: string, + relativePath: string, + content: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.writeFile(target.path, content) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + try { + const fileStats = await lstat(filePath) + if (fileStats.isDirectory()) { + throw new Error('Cannot write to a directory') + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } + await writeFile(filePath, content, 'utf-8') + return { ok: true } + } + + async writeFileExplorerFileBase64( + worktreeSelector: string, + relativePath: string, + contentBase64: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + const content = Buffer.from(contentBase64, 'base64') + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.writeFileBase64(target.path, contentBase64) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, content, { flag: 'wx' }) + return { ok: true } + } + + async writeFileExplorerFileBase64Chunk( + worktreeSelector: string, + relativePath: string, + contentBase64: string, + append: boolean + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + const content = Buffer.from(contentBase64, 'base64') + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.writeFileBase64Chunk(target.path, contentBase64, append) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, content, { flag: append ? 'a' : 'wx' }) + return { ok: true } + } + + async createFileExplorerFile( + worktreeSelector: string, + relativePath: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.createFile(target.path) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirname(filePath), { recursive: true }) + try { + await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) + } catch (error) { + rethrowRuntimeFileCreateError(error, filePath) + } + return { ok: true } + } + + async createFileExplorerDir( + worktreeSelector: string, + relativePath: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.createDir(target.path) + return { ok: true } + } + + const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await assertRuntimePathDoesNotExist(dirPath) + await mkdir(dirPath, { recursive: false }) + return { ok: true } + } + + async createFileExplorerDirNoClobber( + worktreeSelector: string, + relativePath: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.createDirNoClobber(target.path) + return { ok: true } + } + + const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirPath, { recursive: false }) + return { ok: true } + } + + async commitFileExplorerUpload( + worktreeSelector: string, + tempRelativePath: string, + finalRelativePath: string + ): Promise<{ ok: true }> { + const tempTarget = await this.resolveFileExplorerPath(worktreeSelector, tempRelativePath) + const finalTarget = await this.resolveFileExplorerPath(worktreeSelector, finalRelativePath) + const provider = tempTarget.connectionId + ? getSshFilesystemProvider(tempTarget.connectionId) + : null + if (tempTarget.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.copy(tempTarget.path, finalTarget.path) + await provider.deletePath(tempTarget.path, false).catch(() => {}) + return { ok: true } + } + + const store = this.host.requireStore() + const tempPath = await resolveAuthorizedPath(tempTarget.path, store) + const finalPath = await resolveAuthorizedPath(finalTarget.path, store) + await mkdir(dirname(finalPath), { recursive: true }) + await copyFile(tempPath, finalPath, constants.COPYFILE_EXCL) + await rm(tempPath, { force: true }) + return { ok: true } + } + + async renameFileExplorerPath( + worktreeSelector: string, + oldRelativePath: string, + newRelativePath: string + ): Promise<{ ok: true }> { + const oldTarget = await this.resolveFileExplorerPath(worktreeSelector, oldRelativePath) + const newTarget = await this.resolveFileExplorerPath(worktreeSelector, newRelativePath) + const provider = oldTarget.connectionId + ? getSshFilesystemProvider(oldTarget.connectionId) + : null + if (oldTarget.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.rename(oldTarget.path, newTarget.path) + return { ok: true } + } + + const store = this.host.requireStore() + const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true }) + const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true }) + await assertRuntimePathDoesNotExist(newPath) + await rename(oldPath, newPath) + return { ok: true } + } + + async copyFileExplorerPath( + worktreeSelector: string, + sourceRelativePath: string, + destinationRelativePath: string + ): Promise<{ ok: true }> { + const sourceTarget = await this.resolveFileExplorerPath(worktreeSelector, sourceRelativePath) + const destinationTarget = await this.resolveFileExplorerPath( + worktreeSelector, + destinationRelativePath + ) + const provider = sourceTarget.connectionId + ? getSshFilesystemProvider(sourceTarget.connectionId) + : null + if (sourceTarget.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.copy(sourceTarget.path, destinationTarget.path) + return { ok: true } + } + + const store = this.host.requireStore() + const sourcePath = await resolveAuthorizedPath(sourceTarget.path, store, { + preserveSymlink: true + }) + const destinationPath = await resolveAuthorizedPath(destinationTarget.path, store, { + preserveSymlink: true + }) + await mkdir(dirname(destinationPath), { recursive: true }) + // Why: duplicate/copy operations are deconflicted by the caller. COPYFILE_EXCL + // preserves the same no-clobber invariant as the local shell copy IPC. + await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) + return { ok: true } + } + + async deleteFileExplorerPath( + worktreeSelector: string, + relativePath: string, + recursive?: boolean + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.deletePath(target.path, recursive) + return { ok: true } + } + + const targetPath = await resolveAuthorizedPath(target.path, this.host.requireStore(), { + preserveSymlink: true + }) + // Why: a non-local runtime has no client OS Trash/Recycling Bin; server-side + // file mutations are permanent and the renderer confirms before calling this. + await rm(targetPath, { recursive: recursive === true, force: true }) + return { ok: true } + } + + async searchRuntimeFiles( + worktreeSelector: string, + options: Omit + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + const rootPath = target.worktree.path + const searchOptions = { ...options, rootPath } + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + return provider.search(searchOptions) + } + return this.searchLocalRuntimeFiles(rootPath, searchOptions) + } + + async listRuntimeFiles( + worktreeSelector: string, + options: { excludePaths?: string[] } = {} + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + return [] + } + return provider.listFiles(target.worktree.path, { excludePaths: options.excludePaths }) + } + return listQuickOpenFiles(target.worktree.path, this.host.requireStore(), options.excludePaths) + } + + async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + const relativePaths = await provider.listFiles(target.worktree.path) + return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths) + } + return listMarkdownDocuments(target.worktree.path) + } + + async statRuntimeFile( + worktreeSelector: string, + relativePath: string + ): Promise<{ size: number; isDirectory: boolean; mtime: number }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + const fileStat = await provider.stat(target.path) + return { + size: fileStat.size, + isDirectory: fileStat.type === 'directory', + mtime: fileStat.mtime + } + } + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const stats = await stat(filePath) + return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } + } + + private async searchLocalRuntimeFiles( + rootPath: string, + options: SearchOptions + ): Promise { + const authorizedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore()) + const maxResults = Math.max( + 1, + Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) + ) + const rgAvailable = await checkRgAvailable(authorizedRootPath) + if (!rgAvailable) { + return searchWithGitGrep(authorizedRootPath, options, maxResults) + } + + return new Promise((resolvePromise) => { + const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` + const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) + this.activeRuntimeTextSearches.get(searchKey)?.kill() + + const acc = createAccumulator() + let stdoutBuffer = '' + let resolved = false + let child: ChildProcess | null = null + const wslInfo = parseWslPath(authorizedRootPath) + const transformAbsPath = wslInfo + ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) + : undefined + + const resolveOnce = (): void => { + if (resolved) { + return + } + resolved = true + if (this.activeRuntimeTextSearches.get(searchKey) === child) { + this.activeRuntimeTextSearches.delete(searchKey) + } + clearTimeout(killTimeout) + resolvePromise(finalize(acc)) + } + + const processLine = (line: string): void => { + const verdict = ingestRgJsonLine( + line, + authorizedRootPath, + acc, + maxResults, + transformAbsPath + ) + if (verdict === 'stop') { + child?.kill() + } + } + + const nextChild = wslAwareSpawn('rg', rgArgs, { + cwd: authorizedRootPath, + stdio: ['ignore', 'pipe', 'pipe'] + }) + child = nextChild + this.activeRuntimeTextSearches.set(searchKey, nextChild) + + nextChild.stdout!.setEncoding('utf-8') + nextChild.stdout!.on('data', (chunk: string) => { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) + } + }) + nextChild.stderr!.on('data', () => { + // Drain stderr so rg cannot block on a full pipe. + }) + nextChild.once('error', () => resolveOnce()) + nextChild.once('close', () => { + if (stdoutBuffer) { + processLine(stdoutBuffer) + } + resolveOnce() + }) + + const killTimeout = setTimeout(() => { + acc.truncated = true + child?.kill() + }, SEARCH_TIMEOUT_MS) + }) + } + + private async resolveFileExplorerPath( + worktreeSelector: string, + relativePath: string + ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> { + const store = this.host.requireStore() + const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath) + const repo = store.getRepo(worktree.repoId) + return { + worktree, + path: joinWorktreeRelativePath(worktree.path, normalizedRelativePath), + connectionId: repo?.connectionId ?? undefined + } + } + + private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + return [] + } + return provider.listFiles(rootPath) + } + + private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + const fileStat = await provider.stat(filePath) + // Why: the SSH filesystem API does not expose ranged reads here, so reject + // oversized remote previews instead of streaming a large file just to trim it. + if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + const result = await provider.readFile(filePath) + if (result.isBinary) { + throw new Error('binary_file') + } + return result.content + } +} + +function watchWindowsRuntimeFileExplorer( + rootPath: string, + callback: (events: FsChangeEvent[]) => void +): () => void { + let disposed = false + let timer: ReturnType | null = null + + const emitOverflow = (): void => { + timer = null + if (disposed) { + return + } + callback([{ kind: 'overflow', absolutePath: rootPath }]) + } + + const scheduleOverflow = (): void => { + if (disposed) { + return + } + if (timer) { + clearTimeout(timer) + } + timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS) + } + + // Why: Parcel probes Watchman before the Windows backend and its native + // watcher can abort the headless server process. For remote Windows runtimes, + // a conservative overflow refresh is safer than a process-wide native crash. + const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow) + watcher.on('error', (err) => { + console.error('[runtime-files.watch] Windows watcher error', { rootPath, err }) + scheduleOverflow() + }) + + return () => { + disposed = true + if (timer) { + clearTimeout(timer) + timer = null + } + try { + watcher.close() + } catch (err) { + console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err }) + } + } +} + +export function isSafeMobileRelativePath(relativePath: string): boolean { + if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) { + return false + } + const parts = relativePath.replace(/\\/g, '/').split('/') + return parts.every((part) => part !== '' && part !== '.' && part !== '..') +} + +function isMobileMarkdownPath(relativePath: string): boolean { + return /\.(md|mdx|markdown)$/i.test(relativePath) +} + +function isMobileBinaryPath(relativePath: string): boolean { + const basename = basenameFromRelativePath(relativePath) + const dotIndex = basename.lastIndexOf('.') + if (dotIndex <= 0) { + return false + } + return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) +} + +function basenameFromRelativePath(relativePath: string): string { + const normalized = relativePath.replace(/\\/g, '/') + return normalized.slice(normalized.lastIndexOf('/') + 1) +} + +async function isRuntimeDirectoryEntry(entryPath: string): Promise { + try { + return (await stat(entryPath)).isDirectory() + } catch { + return false + } +} + +function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, 8192) + for (let i = 0; i < len; i += 1) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +async function assertRuntimePathDoesNotExist(targetPath: string): Promise { + try { + await lstat(targetPath) + throw new Error( + `A file or folder named '${basename(targetPath)}' already exists in this location` + ) + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } +} + +function rethrowRuntimeFileCreateError(error: unknown, targetPath: string): never { + const name = basename(targetPath) + if (error instanceof Error && 'code' in error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + throw new Error(`A file or folder named '${name}' already exists in this location`) + } + if (code === 'EACCES' || code === 'EPERM') { + throw new Error(`Permission denied: unable to create '${name}'`) + } + } + throw error +} + +async function readLocalMobileFile(filePath: string, store: Store): Promise { + const authorizedPath = await resolveAuthorizedPath(filePath, store) + const fileStat = await stat(authorizedPath) + // Why: mobile file previews are read-only convenience views; cap the read so + // opening a generated log or bundle cannot block the WebSocket like oversized scrollback. + const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1) + const handle = await open(authorizedPath, 'r') + try { + const buffer = Buffer.alloc(readLimit) + const { bytesRead } = await handle.read(buffer, 0, readLimit, 0) + return buffer.subarray(0, bytesRead).toString('utf8') + } finally { + await handle.close() + } +} + +function truncateMobileFilePreview(content: string): { + content: string + truncated: boolean + byteLength: number +} { + const buffer = Buffer.from(content, 'utf8') + if (buffer.byteLength <= MOBILE_FILE_READ_MAX_BYTES) { + return { content, truncated: false, byteLength: buffer.byteLength } + } + return { + content: buffer.subarray(0, MOBILE_FILE_READ_MAX_BYTES).toString('utf8'), + truncated: true, + byteLength: buffer.byteLength + } +} diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts new file mode 100644 index 00000000000..9c405a04599 --- /dev/null +++ b/src/main/runtime/orca-runtime-git.ts @@ -0,0 +1,308 @@ +import type { + GitBranchCompareResult, + GitConflictOperation, + GitDiffResult, + GitPushTarget, + GitStatusResult, + GitUpstreamStatus, + GitWorktreeInfo, + Worktree +} from '../../shared/types' +import { getRemoteFileUrl } from '../git/repo' +import { + bulkStageFiles, + bulkUnstageFiles, + commitChanges, + detectConflictOperation, + discardChanges, + getBranchCompare, + getBranchDiff, + getDiff, + getStatus as getGitStatus, + stageFile, + unstageFile +} from '../git/status' +import { getUpstreamStatus } from '../git/upstream' +import { gitFetch, gitPull, gitPush } from '../git/remote' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { normalizeRuntimeRelativePath } from './runtime-relative-paths' + +export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo } + +export type RuntimeGitCommandHost = { + resolveRuntimeGitTarget( + selector: string + ): Promise<{ worktree: ResolvedRuntimeGitWorktree; connectionId?: string }> +} + +export class RuntimeGitCommands { + constructor(private readonly host: RuntimeGitCommandHost) {} + + async getRuntimeGitStatus(worktreeSelector: string): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getStatus(target.worktree.path) + } + return getGitStatus(target.worktree.path) + } + + async getRuntimeGitConflictOperation(worktreeSelector: string): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.detectConflictOperation(target.worktree.path) + } + return detectConflictOperation(target.worktree.path) + } + + async getRuntimeGitDiff( + worktreeSelector: string, + filePath: string, + staged: boolean, + compareAgainstHead?: boolean + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(filePath) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getDiff(target.worktree.path, relativePath, staged, compareAgainstHead) + } + return getDiff(target.worktree.path, relativePath, staged, compareAgainstHead) + } + + async getRuntimeGitBranchCompare( + worktreeSelector: string, + baseRef: string + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getBranchCompare(target.worktree.path, baseRef) + } + return getBranchCompare(target.worktree.path, baseRef) + } + + async getRuntimeGitUpstreamStatus(worktreeSelector: string): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getUpstreamStatus(target.worktree.path) + } + return getUpstreamStatus(target.worktree.path) + } + + async fetchRuntimeGit(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.fetchRemote(target.worktree.path) + return { ok: true } + } + await gitFetch(target.worktree.path) + return { ok: true } + } + + async pullRuntimeGit(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.pullBranch(target.worktree.path) + return { ok: true } + } + await gitPull(target.worktree.path) + return { ok: true } + } + + async pushRuntimeGit( + worktreeSelector: string, + publish?: boolean, + pushTarget?: GitPushTarget + ): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.pushBranch(target.worktree.path, publish === true, pushTarget) + return { ok: true } + } + await gitPush(target.worktree.path, publish === true, pushTarget) + return { ok: true } + } + + async getRuntimeGitBranchDiff( + worktreeSelector: string, + compare: { mergeBase: string; headOid: string }, + filePath: string, + oldPath?: string + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(filePath) + const oldRelativePath = oldPath ? normalizeRuntimeRelativePath(oldPath) : undefined + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + const results = await provider.getBranchDiff(target.worktree.path, compare.mergeBase, { + includePatch: true, + filePath: relativePath, + oldPath: oldRelativePath + }) + return ( + results[0] ?? { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ) + } + return getBranchDiff(target.worktree.path, { + mergeBase: compare.mergeBase, + headOid: compare.headOid, + filePath: relativePath, + oldPath: oldRelativePath + }) + } + + async commitRuntimeGit( + worktreeSelector: string, + message: string + ): Promise<{ success: boolean; error?: string }> { + if (message.trim().length === 0) { + throw new Error('Commit message is required') + } + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.commit(target.worktree.path, message) + } + return commitChanges(target.worktree.path, message) + } + + async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(filePath) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.stageFile(target.worktree.path, relativePath) + return { ok: true } + } + await stageFile(target.worktree.path, relativePath) + return { ok: true } + } + + async unstageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(filePath) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.unstageFile(target.worktree.path, relativePath) + return { ok: true } + } + await unstageFile(target.worktree.path, relativePath) + return { ok: true } + } + + async bulkStageRuntimeGitPaths( + worktreeSelector: string, + filePaths: string[] + ): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path)) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.bulkStageFiles(target.worktree.path, relativePaths) + return { ok: true } + } + await bulkStageFiles(target.worktree.path, relativePaths) + return { ok: true } + } + + async bulkUnstageRuntimeGitPaths( + worktreeSelector: string, + filePaths: string[] + ): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path)) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.bulkUnstageFiles(target.worktree.path, relativePaths) + return { ok: true } + } + await bulkUnstageFiles(target.worktree.path, relativePaths) + return { ok: true } + } + + async discardRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(filePath) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.discardChanges(target.worktree.path, relativePath) + return { ok: true } + } + await discardChanges(target.worktree.path, relativePath) + return { ok: true } + } + + async getRuntimeGitRemoteFileUrl( + worktreeSelector: string, + relativePath: string, + line: number + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line) + } + return getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line) + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cfba17c75c0..d1b3f1b2e54 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5,11 +5,19 @@ import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree' import { createSetupRunnerScript, getEffectiveHooks, + hasHooksFile, + parseOrcaYaml, runHook, shouldRunSetupForCreate } from '../hooks' +import { getDefaultBaseRef } from '../git/repo' import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' vi.mock('electron', () => ({ app: { @@ -62,7 +70,8 @@ vi.mock('../hooks', () => ({ .fn() .mockImplementation((_repo: never, decision: string) => decision === 'run'), getEffectiveSetupRunPolicy: vi.fn().mockReturnValue('auto'), - hasHooksFile: vi.fn().mockReturnValue(false) + hasHooksFile: vi.fn().mockReturnValue(false), + parseOrcaYaml: vi.fn().mockReturnValue(null) })) vi.mock('../ipc/worktree-logic', async (importOriginal) => { @@ -99,10 +108,14 @@ afterEach(() => { vi.mocked(removeWorktree).mockReset() vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() + vi.mocked(hasHooksFile).mockReset() + vi.mocked(parseOrcaYaml).mockReset() vi.mocked(runHook).mockReset() vi.mocked(shouldRunSetupForCreate).mockReset() vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run') vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(hasHooksFile).mockReturnValue(false) + vi.mocked(parseOrcaYaml).mockReturnValue(null) computeWorktreePathMock.mockReset() ensurePathWithinWorkspaceMock.mockReset() invalidateAuthorizedRootsCacheMock.mockReset() @@ -143,6 +156,20 @@ function createRuntime(): OrcaRuntimeService { return new OrcaRuntimeService(store) } +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + const store = { getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id), getRepos: () => [ @@ -224,10 +251,15 @@ describe('OrcaRuntimeService', () => { expect(runtime.getRuntimeId()).toBeTruthy() }) - it('reports protocol version and minimum compatible mobile version on status', () => { + it('reports runtime protocol, capabilities, and mobile aliases on status', () => { const runtime = createRuntime() const status = runtime.getStatus() + expect(typeof status.runtimeProtocolVersion).toBe('number') + expect(typeof status.minCompatibleRuntimeClientVersion).toBe('number') + expect(status.runtimeProtocolVersion).toBe(status.protocolVersion) + expect(status.minCompatibleRuntimeClientVersion).toBe(status.minCompatibleMobileVersion) + expect(status.capabilities).toContain('terminal.binary-stream.v1') expect(typeof status.protocolVersion).toBe('number') expect(typeof status.minCompatibleMobileVersion).toBe('number') expect(status.protocolVersion).toBeGreaterThanOrEqual(1) @@ -354,12 +386,481 @@ describe('OrcaRuntimeService', () => { }) }) + it('routes SSH-backed forward-slash UNC file and git paths without collapsing the root', async () => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktrees).mockRejectedValue(new Error('local git should not run for SSH repos')) + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '//Server/Share/Repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ], + getRepo: () => ({ + id: TEST_REPO_ID, + path: '//Server/Share/Repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + }) + } + const fsProvider = { readDir: vi.fn().mockResolvedValue([]) } + const gitProvider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '//Server/Share/Repo', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ]), + getStatus: vi.fn().mockResolvedValue({ + branch: 'feature/foo', + files: [], + ahead: 0, + behind: 0, + hasConflicts: false + }) + } + registerSshFilesystemProvider('ssh-1', fsProvider as never) + registerSshGitProvider('ssh-1', gitProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await runtime.readFileExplorerDir('path://server/share/repo', 'src') + await runtime.getRuntimeGitStatus('path://server/share/repo') + await expect(runtime.showRepo('path://server/share/repo')).resolves.toMatchObject({ + path: '//Server/Share/Repo' + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + unregisterSshGitProvider('ssh-1') + } + + expect(listWorktrees).not.toHaveBeenCalled() + expect(gitProvider.listWorktrees).toHaveBeenCalledWith('//Server/Share/Repo') + expect(fsProvider.readDir).toHaveBeenCalledWith('\\\\Server\\Share\\Repo\\src') + expect(gitProvider.getStatus).toHaveBeenCalledWith('//Server/Share/Repo') + }) + it('does not interpret active as a runtime-global worktree selector', async () => { const runtime = new OrcaRuntimeService(store) await expect(runtime.showManagedWorktree('active')).rejects.toThrow('selector_not_found') }) + it('does not reuse stale in-flight worktree scans after creating a worktree', async () => { + const runtime = new OrcaRuntimeService(store) + const staleScan = deferred() + const createdWorktree = { + path: '/tmp/workspaces/cache-race', + head: 'def', + branch: 'cache-race', + isBare: false, + isMainWorktree: false + } + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(listWorktrees) + .mockImplementationOnce(() => staleScan.promise) + .mockResolvedValueOnce([createdWorktree]) + .mockResolvedValueOnce([...MOCK_GIT_WORKTREES, createdWorktree]) + + const staleLookup = runtime.showManagedWorktree(TEST_WORKTREE_ID) + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'cache-race' + }) + const freshLookup = runtime.showManagedWorktree(result.worktree.id) + + staleScan.resolve(MOCK_GIT_WORKTREES) + + await expect(staleLookup).resolves.toMatchObject({ id: TEST_WORKTREE_ID }) + await expect(freshLookup).resolves.toMatchObject({ + id: result.worktree.id, + path: createdWorktree.path + }) + }) + + it('does not run local git when runtime worktree creation targets an SSH repo', async () => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(addWorktree).mockClear() + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + const runtime = new OrcaRuntimeService(remoteStore as never) + + await expect( + runtime.createManagedWorktree({ repoSelector: TEST_REPO_ID, name: 'feature' }) + ).rejects.toThrow('SSH-backed worktree creation is not supported through runtime RPC yet') + + expect(addWorktree).not.toHaveBeenCalled() + expect(listWorktrees).not.toHaveBeenCalled() + }) + + it('removes SSH-backed runtime worktrees through the SSH git provider', async () => { + vi.mocked(listWorktrees).mockClear() + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ], + getRepo: () => ({ + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + }) + } + const gitProvider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: true + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined) + } + registerSshGitProvider('ssh-1', gitProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await runtime.removeManagedWorktree('path:/remote/repo', true) + } finally { + unregisterSshGitProvider('ssh-1') + } + + expect(gitProvider.removeWorktree).toHaveBeenCalledWith('/remote/repo', true) + expect(removeWorktree).not.toHaveBeenCalled() + expect(listWorktrees).not.toHaveBeenCalled() + }) + + it('reads SSH repo hooks through the SSH filesystem provider', async () => { + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: 'C:/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n setup: pnpm install\n', + isBinary: false + }) + } + vi.mocked(parseOrcaYaml).mockReturnValue({ scripts: { setup: 'pnpm install' } }) + registerSshFilesystemProvider('ssh-1', fsProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await expect(runtime.getRepoHooks('id:repo-1')).resolves.toMatchObject({ + hasHooksFile: true, + hooks: { scripts: { setup: 'pnpm install' } }, + source: 'orca.yaml' + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca.yaml') + expect(hasHooksFile).not.toHaveBeenCalled() + expect(getEffectiveHooks).not.toHaveBeenCalled() + }) + + it('uses remote path joins for SSH hook checks and issue-command files', async () => { + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: 'C:/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + const fsProvider = { + readFile: vi.fn(async (filePath: string) => ({ + content: filePath.includes('.orca.yaml') + ? 'scripts:\n setup: pnpm install\n' + : filePath.endsWith('.gitignore') + ? 'node_modules\n' + : 'Fix it', + isBinary: false + })), + writeFile: vi.fn().mockResolvedValue(undefined), + createDir: vi.fn().mockResolvedValue(undefined), + deletePath: vi.fn().mockResolvedValue(undefined) + } + registerSshFilesystemProvider('ssh-1', fsProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({ + hasHooks: true, + mayNeedUpdate: false + }) + await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({ + localContent: 'Fix it', + effectiveContent: 'Fix it', + localFilePath: 'C:\\remote\\repo\\.orca\\issue-command' + }) + await expect(runtime.writeRepoIssueCommand('id:repo-1', 'Ship it')).resolves.toEqual({ + ok: true + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca.yaml') + expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca\\issue-command') + expect(fsProvider.createDir).toHaveBeenCalledWith('C:\\remote\\repo\\.orca') + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:\\remote\\repo\\.orca\\issue-command', + 'Ship it\n' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:\\remote\\repo\\.gitignore', + 'node_modules\n.orca\n' + ) + }) + + it('resolves SSH issue commands from shared orca.yaml and deletes empty overrides', async () => { + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + vi.mocked(parseOrcaYaml).mockReturnValue({ + scripts: {}, + issueCommand: 'claude -p "Fix #{{issue}}"' + }) + const fsProvider = { + readFile: vi.fn(async (filePath: string) => { + if (filePath.endsWith('.orca/issue-command')) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + } + if (filePath.endsWith('orca.yaml')) { + return { content: 'issueCommand: claude -p "Fix #{{issue}}"', isBinary: false } + } + return { content: '', isBinary: false } + }), + writeFile: vi.fn().mockResolvedValue(undefined), + createDir: vi.fn().mockResolvedValue(undefined), + deletePath: vi.fn().mockResolvedValue(undefined) + } + registerSshFilesystemProvider('ssh-1', fsProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({ + localContent: null, + sharedContent: 'claude -p "Fix #{{issue}}"', + effectiveContent: 'claude -p "Fix #{{issue}}"', + localFilePath: '/remote/repo/.orca/issue-command', + source: 'shared' + }) + await expect(runtime.writeRepoIssueCommand('id:repo-1', ' ')).resolves.toEqual({ + ok: true + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') + expect(fsProvider.deletePath).toHaveBeenCalledWith('/remote/repo/.orca/issue-command', false) + expect(fsProvider.writeFile).not.toHaveBeenCalledWith( + '/remote/repo/.orca/issue-command', + expect.anything() + ) + }) + + it('rejects host integration helpers for SSH repos instead of using remote paths locally', async () => { + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + const runtime = new OrcaRuntimeService(remoteStore as never) + + await expect(runtime.getRepoSlug('id:repo-1')).rejects.toThrow( + 'repo_slug_unsupported_for_ssh_repo' + ) + await expect(runtime.listRepoWorkItems('id:repo-1')).rejects.toThrow( + 'repo_work_items_unsupported_for_ssh_repo' + ) + }) + + it('treats SSH worktree drift as unknown without local git probes', async () => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(getDefaultBaseRef).mockClear() + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ], + getWorktreeMeta: () => null + } + const gitProvider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: true + } + ]) + } + registerSshGitProvider('ssh-1', gitProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await expect(runtime.probeWorktreeDrift('path:/remote/repo')).resolves.toBeNull() + } finally { + unregisterSshGitProvider('ssh-1') + } + + expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo') + expect(getDefaultBaseRef).not.toHaveBeenCalled() + expect(listWorktrees).not.toHaveBeenCalled() + }) + + it('deduplicates runtime repo paths with Windows/UNC comparison semantics', async () => { + const added: Record[] = [] + const uncStore = { + ...store, + getRepos: () => [ + { + id: 'repo-unc', + path: '//Server/Share/Repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'folder' + }, + ...added + ], + addRepo: (repo: Record) => { + added.push(repo) + }, + getRepo: (id: string) => [...uncStore.getRepos()].find((repo) => repo.id === id) as never + } + const runtime = new OrcaRuntimeService(uncStore as never) + + const repo = await runtime.addRepo('//server/share/repo', 'folder') + + expect(repo).toMatchObject({ id: 'repo-unc', path: '//Server/Share/Repo' }) + expect(added).toHaveLength(0) + }) + + it('associates controller PTYs with mixed-case Windows and UNC cwd paths', async () => { + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: 'C:\\Repo', + head: 'abc', + branch: 'feature/windows', + isBare: false, + isMainWorktree: true + }, + { + path: '//Server/Share/Repo', + head: 'def', + branch: 'feature/unc', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { id: 'pty-windows', cwd: 'c:\\repo\\src', title: 'Windows shell' }, + { id: 'pty-unc', cwd: '//server/share/repo/src', title: 'UNC shell' } + ] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + const terminals = await runtime.listTerminals() + + expect(terminals.terminals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + worktreeId: `${TEST_REPO_ID}::C:\\Repo`, + worktreePath: 'C:\\Repo' + }), + expect.objectContaining({ + worktreeId: `${TEST_REPO_ID}:://Server/Share/Repo`, + worktreePath: '//Server/Share/Repo' + }) + ]) + ) + }) + it('reads bounded terminal output and writes through the PTY controller', async () => { const writes: string[] = [] const runtime = new OrcaRuntimeService(store) @@ -1102,6 +1603,55 @@ describe('OrcaRuntimeService', () => { } }) + it('creates mobile session terminals in a headless runtime server', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + preAllocatedHandle: expect.stringMatching(/^term_/) + }) + ) + expect(result.tab).toMatchObject({ + type: 'terminal', + status: 'ready', + terminal: expect.stringMatching(/^term_/), + isActive: true + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(listed.tabs).toEqual([ + expect.objectContaining({ + id: result.tab.id, + status: 'ready', + terminal: result.tab.terminal + }) + ]) + }) + + it('reports browser tab creation as unsupported for headless runtime servers', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await expect( + runtime.browserTabCreate({ worktree: `id:${TEST_WORKTREE_ID}`, url: 'https://example.com' }) + ).rejects.toMatchObject({ + code: 'browser_error', + message: expect.stringContaining('headless orca serve') + }) + }) + it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => { const runtime = new OrcaRuntimeService(store) const db = new OrchestrationDb(':memory:') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2b053f310dc..38fa678ec92 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- Why: the Orca runtime is the authoritative live control plane for the CLI, so handle validation, selector resolution, wait state, and summaries are kept together to avoid split-brain behavior. */ +/* eslint-disable max-lines -- Why: OrcaRuntimeService still owns the mutable live graph, PTY handles, waiters, mobile floor/layout state, and managed-worktree reconciliation. Stateless browser and file command adapters live beside it; the remaining split points need state-owner extraction before enforcing max-lines. */ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ import { @@ -7,29 +7,40 @@ import { isShellProcess } from '../../shared/agent-detection' import type { AgentStatus } from '../../shared/agent-detection' -import { gitExecFileAsync } from '../git/runner' +import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { createHash, randomUUID } from 'crypto' -import { join, posix, win32 } from 'path' -import { open, rm, stat } from 'fs/promises' +import { basename, isAbsolute, join } from 'path' +import { mkdir, readdir, rm, stat } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' import type { CreateWorktreeResult, + GitPushTarget, + GitWorktreeInfo, GlobalSettings, Repo, StatsSummary, + Worktree, + WorktreeMeta, WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent, - WorktreeStartupLaunch + WorktreeStartupLaunch, + LinearIssueUpdate, + TuiAgent } from '../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeId } from '../../shared/worktree-id' import { isFolderRepo } from '../../shared/repo-kind' import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' import { FIRST_PANE_ID } from '../../shared/pane-key' import { - DESKTOP_PROTOCOL_VERSION, - MIN_COMPATIBLE_MOBILE_VERSION + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../shared/cross-platform-path' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_CAPABILITIES, + RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' import type { RuntimeGraphStatus, @@ -57,87 +68,146 @@ import type { RuntimeMobileSessionCreateTerminalResult, RuntimeMobileSessionClientTab, RuntimeMobileSessionMarkdownTab, + RuntimeMobileSessionTerminalTab, RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot, - RuntimeFileListResult, - RuntimeFileOpenResult, - RuntimeFileReadResult, RuntimeSyncWindowGraph, - RuntimeWorktreeListResult, - BrowserSnapshotResult, - BrowserClickResult, - BrowserGotoResult, - BrowserFillResult, - BrowserTypeResult, - BrowserSelectResult, - BrowserScrollResult, - BrowserBackResult, - BrowserReloadResult, - BrowserProfileCreateResult, - BrowserProfileDeleteResult, - BrowserProfileListResult, - BrowserScreenshotResult, - BrowserEvalResult, - BrowserTabCurrentResult, - BrowserTabListResult, - BrowserTabProfileCloneResult, - BrowserTabProfileShowResult, - BrowserTabSetProfileResult, - BrowserTabShowResult, - BrowserTabSwitchResult, - BrowserHoverResult, - BrowserDragResult, - BrowserUploadResult, - BrowserWaitResult, - BrowserCheckResult, - BrowserFocusResult, - BrowserClearResult, - BrowserSelectAllResult, - BrowserKeypressResult, - BrowserPdfResult, - BrowserCookieGetResult, - BrowserCookieSetResult, - BrowserCookieDeleteResult, - BrowserViewportResult, - BrowserGeolocationResult, - BrowserInterceptEnableResult, - BrowserInterceptDisableResult, - BrowserCaptureStartResult, - BrowserCaptureStopResult, - BrowserConsoleResult, - BrowserNetworkLogResult + RuntimeWorktreeListResult } from '../../shared/runtime-types' +import { RuntimeBrowserCommands } from './orca-runtime-browser' +import { RuntimeFileCommands } from './orca-runtime-files' +import { RuntimeGitCommands } from './orca-runtime-git' +import { joinWorktreeRelativePath } from './runtime-relative-paths' import { app, BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import { browserManager } from '../browser/browser-manager' -import { BrowserError } from '../browser/cdp-bridge' -import { browserSessionRegistry } from '../browser/browser-session-registry' -import { waitForTabRegistration } from '../ipc/browser' -import { getPRForBranch } from '../github/client' +import { + getPRForBranch, + getPullRequestPushTarget, + getRepoSlug, + getWorkItem, + listWorkItems, + countWorkItems, + getPRChecks, + getPRComments, + getIssue, + resolveReviewThread, + getWorkItemByOwnerRepo, + updatePRTitle, + mergePR, + createIssue, + updateIssue, + addIssueComment, + addPRReviewComment, + addPRReviewCommentReply, + listLabels, + listAssignableUsers +} from '../github/client' +import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' +import { getRateLimit } from '../github/rate-limit' +import type { + GitHubIssueUpdate, + GitHubPRFile, + GitHubPRReviewCommentInput +} from '../../shared/types' +import type { HostedReviewInfo } from '../../shared/hosted-review' +import { getHostedReviewForBranch as getHostedReviewForBranchFromRepo } from '../source-control/hosted-review' +import { + connect as connectLinear, + disconnect as disconnectLinear, + getStatus as getLinearStatus, + testConnection as testLinearConnection +} from '../linear/client' +import { + addIssueComment as addLinearIssueComment, + createIssue as createLinearIssue, + getIssue as getLinearIssue, + getIssueComments as getLinearIssueComments, + listIssues as listLinearIssues, + searchIssues as searchLinearIssues, + updateIssue as updateLinearIssue, + type LinearListFilter +} from '../linear/issues' +import { + getTeamLabels as getLinearTeamLabels, + getTeamMembers as getLinearTeamMembers, + getTeamStates as getLinearTeamStates, + listTeams as listLinearTeams +} from '../linear/teams' +import { + clearProjectItemFieldValue, + getProjectViewTable, + listAccessibleProjects, + listProjectViews, + resolveProjectRef, + addIssueCommentBySlug, + deleteIssueCommentBySlug, + listAssignableUsersBySlug, + listIssueTypesBySlug, + listLabelsBySlug, + updateIssueCommentBySlug, + updateIssueBySlug, + updateIssueTypeBySlug, + updateProjectItemFieldValue, + updatePullRequestBySlug +} from '../github/project-view' +import type { + ClearProjectItemFieldArgs, + GetProjectViewTableArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ResolveProjectRefArgs, + AddIssueCommentBySlugArgs, + DeleteIssueCommentBySlugArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdateProjectItemFieldArgs, + UpdatePullRequestBySlugArgs +} from '../../shared/github-project-types' import { getGitUsername, + getBaseRefDefault, getDefaultBaseRef, + getDefaultRemote, getBranchConflictKind, isGitRepo, getRepoName, searchBaseRefs, + getRemoteCount, + normalizeRefSearchQuery, + parseAndFilterSearchRefs, + parseRemoteCount, + resolveDefaultBaseRefViaExec, + buildSearchBaseRefsArgv, getRemoteDrift, getRecentDriftSubjects } from '../git/repo' -import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree' -import { listQuickOpenFiles } from '../ipc/filesystem-list-files' -import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { listWorktrees, addWorktree, addSparseWorktree, removeWorktree } from '../git/worktree' +import { isENOENT } from '../ipc/filesystem-auth' import { createSetupRunnerScript, getEffectiveHooks, getEffectiveSetupRunPolicy, + hasUnrecognizedOrcaYamlKeys, hasHooksFile, + loadHooks, + parseOrcaYaml, + readIssueCommand, runHook, - shouldRunSetupForCreate + shouldRunSetupForCreate, + writeIssueCommand } from '../hooks' import { REPO_COLORS, getDefaultVoiceSettings } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' +import { createWorktreeSymlinks } from '../ipc/worktree-symlinks' +import { + configureCreatedWorktreePushTarget, + prepareWorktreePushTarget +} from '../ipc/worktree-remote' +import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import { AgentDetector } from '../stats/agent-detector' @@ -156,8 +226,9 @@ import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { HeadlessEmulator } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' -import type { IPtyProvider } from '../providers/types' +import type { IFilesystemProvider, IPtyProvider } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService } from '../codex-accounts/service' import type { RateLimitService } from '../rate-limits/service' @@ -170,6 +241,8 @@ import type { NoteDeleteArgs, NoteDeleteResult, NoteLinkArgs, + NoteLink, + NoteLinkKind, NoteListArgs, NoteListResult, NoteMutationResult, @@ -210,6 +283,8 @@ type RuntimeStore = { getRepo: Store['getRepo'] addRepo: Store['addRepo'] updateRepo: Store['updateRepo'] + removeRepo?: Store['removeRepo'] + reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] @@ -222,6 +297,7 @@ type RuntimeStore = { refreshLocalBaseRefOnWorktreeCreate: boolean branchPrefix: string branchPrefixCustom: string + experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null voice?: VoiceSettings } @@ -290,6 +366,7 @@ type RuntimePtyController = { write(ptyId: string, data: string): boolean kill(ptyId: string): boolean getForegroundProcess(ptyId: string): Promise + hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> @@ -304,25 +381,6 @@ type RuntimePtyController = { getSize?(ptyId: string): { cols: number; rows: number } | null } -const MOBILE_FILE_LIST_LIMIT = 5000 -const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 -const MOBILE_BINARY_EXTENSIONS = new Set([ - '.avif', - '.bmp', - '.gif', - '.heic', - '.ico', - '.jpeg', - '.jpg', - '.mov', - '.mp3', - '.mp4', - '.pdf', - '.png', - '.webp', - '.zip' -]) - type RuntimeNotifier = { worktreesChanged(repoId: string): void worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void @@ -403,38 +461,24 @@ type MessageWaiter = { timeout: NodeJS.Timeout | null } -type ResolvedWorktree = { - id: string - repoId: string - path: string - branch: string - linkedIssue: number | null - git: { - path: string - head: string - branch: string - isBare: boolean - isMainWorktree: boolean - } - displayName: string - comment: string +function omitUndefinedProperties>(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined) + ) as Partial } -type BrowserCommandTargetParams = { - worktree?: string - page?: string -} - -type ResolvedBrowserCommandTarget = { - worktreeId?: string - browserPageId?: string -} +type ResolvedWorktree = Worktree & { git: GitWorktreeInfo } type ResolvedWorktreeCache = { expiresAt: number worktrees: ResolvedWorktree[] } +type ResolvedWorktreeInFlight = { + generation: number + promise: Promise +} + export type MobileNotificationEvent = { source: 'agent-task-complete' | 'terminal-bell' | 'test' title: string @@ -510,7 +554,8 @@ export class OrcaRuntimeService { private notifier: RuntimeNotifier | null = null private agentBrowserBridge: AgentBrowserBridge | null = null private resolvedWorktreeCache: ResolvedWorktreeCache | null = null - private resolvedWorktreeInFlight: Promise | null = null + private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null + private resolvedWorktreeGeneration = 0 private agentDetector: AgentDetector | null = null private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() @@ -525,6 +570,7 @@ export class OrcaRuntimeService { string, Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void> >() + private driverListeners = new Map void>>() private subscriptionCleanups = new Map void>() // Why: index of subscriptionIds by per-WebSocket connectionId so the // server can sweep all subscriptions for a closing socket without @@ -814,8 +860,11 @@ export class OrcaRuntimeService { authoritativeWindowId: this.authoritativeWindowId, liveTabCount: this.tabs.size, liveLeafCount: this.leaves.size, - protocolVersion: DESKTOP_PROTOCOL_VERSION, - minCompatibleMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [...RUNTIME_CAPABILITIES], + protocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } } @@ -989,7 +1038,12 @@ export class OrcaRuntimeService { throw new Error('tab_not_found') } if (tab.type === 'terminal') { - this.notifier?.closeTerminal(tab.parentTabId) + const pty = this.findPtyForMobileTerminalTab(tab) + if (pty) { + this.ptyController?.kill(pty.ptyId) + } else { + this.notifier?.closeTerminal(tab.parentTabId) + } } else { this.notifier?.closeSessionTab?.(tab.id, worktreeId) } @@ -1020,117 +1074,112 @@ export class OrcaRuntimeService { return await this.notifier.saveMobileMarkdownTab(worktreeId, tabId, baseVersion, content) } - async listMobileFiles(worktreeSelector: string): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') + private readonly fileCommands = new RuntimeFileCommands({ + getRuntimeId: () => this.runtimeId, + requireStore: () => this.requireStore(), + resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), + resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), + openFile: (worktreeId, filePath, relativePath) => { + if (!this.notifier?.openFile) { + throw new Error('renderer_unavailable') + } + this.notifier.openFile(worktreeId, filePath, relativePath) } + }) + + listMobileFiles: RuntimeFileCommands['listMobileFiles'] = this.fileCommands.listMobileFiles.bind( + this.fileCommands + ) + openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind( + this.fileCommands + ) + readMobileFile: RuntimeFileCommands['readMobileFile'] = this.fileCommands.readMobileFile.bind( + this.fileCommands + ) + readFileExplorerDir: RuntimeFileCommands['readFileExplorerDir'] = + this.fileCommands.readFileExplorerDir.bind(this.fileCommands) + watchFileExplorer: RuntimeFileCommands['watchFileExplorer'] = + this.fileCommands.watchFileExplorer.bind(this.fileCommands) + readFileExplorerPreview: RuntimeFileCommands['readFileExplorerPreview'] = + this.fileCommands.readFileExplorerPreview.bind(this.fileCommands) + writeFileExplorerFile: RuntimeFileCommands['writeFileExplorerFile'] = + this.fileCommands.writeFileExplorerFile.bind(this.fileCommands) + writeFileExplorerFileBase64: RuntimeFileCommands['writeFileExplorerFileBase64'] = + this.fileCommands.writeFileExplorerFileBase64.bind(this.fileCommands) + writeFileExplorerFileBase64Chunk: RuntimeFileCommands['writeFileExplorerFileBase64Chunk'] = + this.fileCommands.writeFileExplorerFileBase64Chunk.bind(this.fileCommands) + createFileExplorerFile: RuntimeFileCommands['createFileExplorerFile'] = + this.fileCommands.createFileExplorerFile.bind(this.fileCommands) + createFileExplorerDir: RuntimeFileCommands['createFileExplorerDir'] = + this.fileCommands.createFileExplorerDir.bind(this.fileCommands) + createFileExplorerDirNoClobber: RuntimeFileCommands['createFileExplorerDirNoClobber'] = + this.fileCommands.createFileExplorerDirNoClobber.bind(this.fileCommands) + commitFileExplorerUpload: RuntimeFileCommands['commitFileExplorerUpload'] = + this.fileCommands.commitFileExplorerUpload.bind(this.fileCommands) + renameFileExplorerPath: RuntimeFileCommands['renameFileExplorerPath'] = + this.fileCommands.renameFileExplorerPath.bind(this.fileCommands) + copyFileExplorerPath: RuntimeFileCommands['copyFileExplorerPath'] = + this.fileCommands.copyFileExplorerPath.bind(this.fileCommands) + deleteFileExplorerPath: RuntimeFileCommands['deleteFileExplorerPath'] = + this.fileCommands.deleteFileExplorerPath.bind(this.fileCommands) + searchRuntimeFiles: RuntimeFileCommands['searchRuntimeFiles'] = + this.fileCommands.searchRuntimeFiles.bind(this.fileCommands) + listRuntimeFiles: RuntimeFileCommands['listRuntimeFiles'] = + this.fileCommands.listRuntimeFiles.bind(this.fileCommands) + listRuntimeMarkdownDocuments: RuntimeFileCommands['listRuntimeMarkdownDocuments'] = + this.fileCommands.listRuntimeMarkdownDocuments.bind(this.fileCommands) + statRuntimeFile: RuntimeFileCommands['statRuntimeFile'] = this.fileCommands.statRuntimeFile.bind( + this.fileCommands + ) + + private readonly gitCommands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector) + }) + + getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] = + this.gitCommands.getRuntimeGitStatus.bind(this.gitCommands) + getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] = + this.gitCommands.getRuntimeGitConflictOperation.bind(this.gitCommands) + getRuntimeGitDiff: RuntimeGitCommands['getRuntimeGitDiff'] = + this.gitCommands.getRuntimeGitDiff.bind(this.gitCommands) + getRuntimeGitBranchCompare: RuntimeGitCommands['getRuntimeGitBranchCompare'] = + this.gitCommands.getRuntimeGitBranchCompare.bind(this.gitCommands) + getRuntimeGitUpstreamStatus: RuntimeGitCommands['getRuntimeGitUpstreamStatus'] = + this.gitCommands.getRuntimeGitUpstreamStatus.bind(this.gitCommands) + fetchRuntimeGit: RuntimeGitCommands['fetchRuntimeGit'] = this.gitCommands.fetchRuntimeGit.bind( + this.gitCommands + ) + pullRuntimeGit: RuntimeGitCommands['pullRuntimeGit'] = this.gitCommands.pullRuntimeGit.bind( + this.gitCommands + ) + pushRuntimeGit: RuntimeGitCommands['pushRuntimeGit'] = this.gitCommands.pushRuntimeGit.bind( + this.gitCommands + ) + getRuntimeGitBranchDiff: RuntimeGitCommands['getRuntimeGitBranchDiff'] = + this.gitCommands.getRuntimeGitBranchDiff.bind(this.gitCommands) + commitRuntimeGit: RuntimeGitCommands['commitRuntimeGit'] = this.gitCommands.commitRuntimeGit.bind( + this.gitCommands + ) + stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] = + this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands) + unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] = + this.gitCommands.unstageRuntimeGitPath.bind(this.gitCommands) + bulkStageRuntimeGitPaths: RuntimeGitCommands['bulkStageRuntimeGitPaths'] = + this.gitCommands.bulkStageRuntimeGitPaths.bind(this.gitCommands) + bulkUnstageRuntimeGitPaths: RuntimeGitCommands['bulkUnstageRuntimeGitPaths'] = + this.gitCommands.bulkUnstageRuntimeGitPaths.bind(this.gitCommands) + discardRuntimeGitPath: RuntimeGitCommands['discardRuntimeGitPath'] = + this.gitCommands.discardRuntimeGitPath.bind(this.gitCommands) + getRuntimeGitRemoteFileUrl: RuntimeGitCommands['getRuntimeGitRemoteFileUrl'] = + this.gitCommands.getRuntimeGitRemoteFileUrl.bind(this.gitCommands) + + private async resolveRuntimeGitTarget( + worktreeSelector: string + ): Promise<{ worktree: ResolvedWorktree; connectionId?: string }> { + const store = this.requireStore() const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const repo = this.store.getRepo(worktree.repoId) - const connectionId = repo?.connectionId ?? undefined - const files = connectionId - ? await this.listRemoteMobileFiles(worktree.path, connectionId) - : await listQuickOpenFiles(worktree.path, this.store as unknown as Store) - const entries = files - .filter((relativePath) => isSafeMobileRelativePath(relativePath)) - .sort((a, b) => a.localeCompare(b)) - .slice(0, MOBILE_FILE_LIST_LIMIT) - .map((relativePath) => ({ - relativePath, - basename: basenameFromRelativePath(relativePath), - kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) - })) - - return { - worktree: worktree.id, - rootPath: worktree.path, - files: entries, - totalCount: files.length, - truncated: files.length > MOBILE_FILE_LIST_LIMIT - } - } - - async openMobileFile( - worktreeSelector: string, - relativePath: string - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - const kind = isMobileBinaryPath(relativePath) - ? 'binary' - : isMobileMarkdownPath(relativePath) - ? 'markdown' - : 'text' - if (kind === 'binary') { - return { worktree: worktree.id, relativePath, kind, opened: false } - } - if (!this.notifier?.openFile) { - throw new Error('renderer_unavailable') - } - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - this.notifier.openFile(worktree.id, filePath, relativePath) - return { worktree: worktree.id, relativePath, kind, opened: true } - } - - async readMobileFile( - worktreeSelector: string, - relativePath: string - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - if (isMobileBinaryPath(relativePath)) { - throw new Error('binary_file') - } - - const repo = this.store.getRepo(worktree.repoId) - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - const content = repo?.connectionId - ? await this.readRemoteMobileFile(filePath, repo.connectionId) - : await readLocalMobileFile(filePath, this.store as unknown as Store) - const truncated = truncateMobileFilePreview(content) - - return { - worktree: worktree.id, - relativePath, - content: truncated.content, - truncated: truncated.truncated, - byteLength: truncated.byteLength - } - } - - private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - return [] - } - return provider.listFiles(rootPath) - } - - private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error('remote_filesystem_unavailable') - } - const fileStat = await provider.stat(filePath) - // Why: the SSH filesystem API does not expose ranged reads here, so reject - // oversized remote previews instead of streaming a large file just to trim it. - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - const result = await provider.readFile(filePath) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content + const repo = store.getRepo(worktree.repoId) + return { worktree, connectionId: repo?.connectionId ?? undefined } } onMobileSessionTabsChanged( @@ -1319,6 +1368,21 @@ export class OrcaRuntimeService { } } + subscribeToDriverChanges(ptyId: string, listener: (driver: DriverState) => void): () => void { + let listeners = this.driverListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.driverListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.driverListeners.delete(ptyId) + } + } + } + private notifyFitOverrideListeners( ptyId: string, mode: 'mobile-fit' | 'desktop-fit', @@ -1619,9 +1683,7 @@ export class OrcaRuntimeService { // listener leaks in dataListeners and duplicates every PTY data event. const existing = this.subscriptionCleanups.get(subscriptionId) if (existing) { - existing() - // Why: existing() already evicts itself from the per-connection index - // via cleanupSubscription, so no extra bookkeeping is needed here. + this.cleanupSubscription(subscriptionId) } this.subscriptionCleanups.set(subscriptionId, cleanup) if (connectionId) { @@ -2318,6 +2380,12 @@ export class OrcaRuntimeService { this.currentDriver.set(ptyId, next) } this.notifier?.terminalDriverChanged(ptyId, next) + const listeners = this.driverListeners.get(ptyId) + if (listeners) { + for (const listener of listeners) { + listener(next) + } + } } // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / @@ -2398,6 +2466,36 @@ export class OrcaRuntimeService { return true } + // Why: remote desktop clients do not have the local `pty:resize` IPC path. + // Their measured xterm size still has to resize the source PTY so TUIs + // reflow to the visible client dimensions. + async updateDesktopViewport( + ptyId: string, + viewport: { cols: number; rows: number } + ): Promise { + if ( + this.isResizeSuppressed() || + this.getDriver(ptyId).kind === 'mobile' || + this.terminalFitOverrides.has(ptyId) + ) { + return false + } + const cols = Math.max(20, Math.min(240, Math.round(viewport.cols))) + const rows = Math.max(8, Math.min(120, Math.round(viewport.rows))) + let resized = false + try { + resized = this.ptyController?.resize?.(ptyId, cols, rows) ?? false + } catch { + return false + } + if (!resized) { + return false + } + this.resizeHeadlessTerminal(ptyId, cols, rows) + this.onExternalPtyResize(ptyId, cols, rows) + return true + } + // Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take // back" / "Restore" button). Forces the PTY back to desktop dims and // flips the driver to `desktop`, suppressing further mobile-driven dim @@ -3867,11 +3965,16 @@ export class OrcaRuntimeService { if (!this.store) { throw new Error('runtime_unavailable') } + if (!isAbsolute(path)) { + // Why: remote clients may run in a different cwd than the server. Require + // server-side repo paths to be explicit so `orca serve` cwd is irrelevant. + throw new Error('Repo path must be an absolute path') + } if (kind === 'git' && !isGitRepo(path)) { throw new Error(`Not a valid git repository: ${path}`) } - const existing = this.store.getRepos().find((repo) => repo.path === path) + const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, path)) if (existing) { return existing } @@ -3890,6 +3993,182 @@ export class OrcaRuntimeService { return this.store.getRepo(repo.id) ?? repo } + async createRepo( + parentPath: string, + name: string, + kind: 'git' | 'folder' = 'git' + ): Promise<{ repo: Repo } | { error: string }> { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const trimmedName = name.trim() + const trimmedParentPath = parentPath.trim() + const repoKind: 'git' | 'folder' = kind === 'folder' ? 'folder' : 'git' + if (!trimmedName) { + return { error: 'Name cannot be empty' } + } + if (/[\\/]/.test(trimmedName) || trimmedName === '.' || trimmedName === '..') { + return { error: 'Name cannot contain slashes or be "." / ".."' } + } + if (!trimmedParentPath) { + return { error: 'Parent directory is required' } + } + if (!isAbsolute(trimmedParentPath)) { + return { error: 'Parent directory must be an absolute path' } + } + + const targetPath = join(trimmedParentPath, trimmedName) + const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, targetPath)) + if (existing) { + return { repo: existing } + } + + let createdDir = false + try { + const existingStat = await stat(targetPath).catch((error: unknown) => { + if (isENOENT(error)) { + return null + } + throw error + }) + if (existingStat) { + if (!existingStat.isDirectory()) { + return { error: `"${trimmedName}" already exists at this location and is not a folder.` } + } + const entries = await readdir(targetPath) + if (entries.length > 0) { + return { error: `"${trimmedName}" already exists at this location and is not empty.` } + } + } else { + await mkdir(targetPath, { recursive: false }) + createdDir = true + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to prepare directory: ${message}` } + } + + if (repoKind === 'git') { + let step: 'init' | 'commit' = 'init' + try { + await gitExecFileAsync(['init'], { cwd: targetPath }) + step = 'commit' + await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], { + cwd: targetPath + }) + } catch (error) { + if (createdDir) { + await rm(targetPath, { recursive: true, force: true }).catch(() => {}) + } else if (step === 'commit') { + await rm(join(targetPath, '.git'), { recursive: true, force: true }).catch(() => {}) + } + const message = error instanceof Error ? error.message : String(error) + if ( + step === 'commit' && + /Please tell me who you are|user\.name|user\.email/i.test(message) + ) { + return { + error: + 'Git author identity is not configured. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then try again.' + } + } + const stepLabel = + step === 'init' + ? 'Failed to initialize git repository' + : 'Failed to create initial commit' + return { error: `${stepLabel}: ${message}` } + } + } + + const raceWinner = this.store + .getRepos() + .find((repo) => runtimePathsEqual(repo.path, targetPath)) + if (raceWinner) { + return { repo: raceWinner } + } + + const repo: Repo = { + id: randomUUID(), + path: targetPath, + displayName: trimmedName, + badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + addedAt: Date.now(), + kind: repoKind + } + this.store.addRepo(repo) + invalidateAuthorizedRootsCache() + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return { repo: this.store.getRepo(repo.id) ?? repo } + } + + async cloneRepo(url: string, destination: string): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const trimmedUrl = url.trim() + const trimmedDestination = destination.trim() + const repoName = basename(trimmedUrl.replace(/\.git\/?$/, '')) + if (!repoName) { + throw new Error('Could not determine repository name from URL') + } + if (!trimmedDestination) { + throw new Error('Clone destination is required') + } + if (!isAbsolute(trimmedDestination)) { + throw new Error('Clone destination must be an absolute path') + } + await mkdir(trimmedDestination, { recursive: true }) + const clonePath = join(trimmedDestination, repoName) + await new Promise((resolve, reject) => { + const proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], { + cwd: trimmedDestination, + stdio: ['ignore', 'ignore', 'pipe'] + }) + let stderrTail = '' + proc.stderr?.on('data', (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString()).slice(-4096) + }) + proc.on('error', (error) => reject(new Error(`Clone failed: ${error.message}`))) + proc.on('close', (code, signal) => { + if (signal === 'SIGTERM') { + reject(new Error('Clone aborted')) + } else if (code === 0) { + resolve() + } else { + const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' + reject(new Error(`Clone failed: ${lastLine}`)) + } + }) + }) + + const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, clonePath)) + if (existing) { + if (isFolderRepo(existing)) { + const updated = this.store.updateRepo(existing.id, { kind: 'git' }) + if (updated) { + this.notifier?.reposChanged() + return updated + } + } + return existing + } + + const repo: Repo = { + id: randomUUID(), + path: clonePath, + displayName: getRepoName(clonePath), + badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + addedAt: Date.now(), + kind: 'git' + } + this.store.addRepo(repo) + invalidateAuthorizedRootsCache() + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return this.store.getRepo(repo.id) ?? repo + } + async showRepo(repoSelector: string): Promise { return await this.resolveRepoSelector(repoSelector) } @@ -3911,6 +4190,75 @@ export class OrcaRuntimeService { return updated } + async updateRepo( + repoSelector: string, + updates: Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'hookSettings' + | 'worktreeBaseRef' + | 'kind' + | 'symlinkPaths' + | 'issueSourcePreference' + > + > + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + const updated = this.store.updateRepo(repo.id, omitUndefinedProperties(updates)) + if (!updated) { + throw new Error('repo_not_found') + } + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return updated + } + + async removeRepo(repoSelector: string): Promise<{ removed: true }> { + if (!this.store?.removeRepo) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + this.store.removeRepo(repo.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifier?.reposChanged() + return { removed: true } + } + + async inspectTerminalProcess( + terminalSelector: string + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + const leaf = this.resolveLeafForHandle(terminalSelector) + if (!leaf?.ptyId || !this.ptyController) { + return { foregroundProcess: null, hasChildProcesses: false } + } + const foregroundProcess = await this.ptyController.getForegroundProcess(leaf.ptyId) + const hasChildProcesses = + (await this.ptyController.hasChildProcesses?.(leaf.ptyId).catch(() => false)) ?? false + return { foregroundProcess, hasChildProcesses } + } + + reorderRepos(orderedIds: string[]): { status: 'applied' | 'rejected' } { + if (!this.store?.reorderRepos) { + throw new Error('runtime_unavailable') + } + // Why: remote clients can race repo add/remove on the server just like + // local drag-reorder can race another window. Let the store validate the + // full permutation and signal a resync-worthy rejection. + const applied = this.store.reorderRepos(orderedIds) + if (!applied) { + return { status: 'rejected' } + } + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return { status: 'applied' } + } + async searchRepoRefs( repoSelector: string, query: string, @@ -3926,15 +4274,462 @@ export class OrcaRuntimeService { truncated: false } } - const refs = await searchBaseRefs(repo.path, query, limit + 1) + const refs = repo.connectionId + ? await this.searchRemoteRepoRefs(repo, query, limit + 1) + : await searchBaseRefs(repo.path, query, limit + 1) return { refs: refs.slice(0, limit), truncated: refs.length > limit } } + async getRepoBaseRefDefault( + repoSelector: string + ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { defaultBaseRef: null, remoteCount: 0 } + } + if (repo.connectionId) { + return this.getRemoteRepoBaseRefDefault(repo) + } + const [defaultBaseRef, remoteCount] = await Promise.all([ + getBaseRefDefault(repo.path), + getRemoteCount(repo.path) + ]) + return { defaultBaseRef, remoteCount } + } + + private async getRemoteRepoBaseRefDefault( + repo: Repo + ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null + if (!provider) { + return { defaultBaseRef: null, remoteCount: 0 } + } + const [defaultBaseRef, remoteCount] = await Promise.all([ + resolveDefaultBaseRefViaExec(async (argv) => { + try { + return await provider.exec(argv, repo.path) + } catch (err) { + if (argv[0] === 'symbolic-ref') { + console.warn('[runtime:repo.baseRefDefault] SSH symbolic-ref failed', { + path: repo.path, + err + }) + } + throw err + } + }), + provider + .exec(['remote'], repo.path) + .then((result) => parseRemoteCount(result.stdout)) + .catch((err) => { + console.warn('[runtime:repo.baseRefDefault] SSH git remote count failed', { + path: repo.path, + err + }) + return 0 + }) + ]) + return { defaultBaseRef, remoteCount } + } + + private async searchRemoteRepoRefs(repo: Repo, query: string, limit: number): Promise { + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null + if (!provider) { + return [] + } + const normalizedQuery = normalizeRefSearchQuery(query) + if (!normalizedQuery) { + return [] + } + try { + const result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path) + return parseAndFilterSearchRefs(result.stdout, limit) + } catch (err) { + console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', { + path: repo.path, + err + }) + return [] + } + } + + private assertHostIntegrationRepoIsLocal(repo: Repo, operation: string): void { + if (repo.connectionId) { + throw new Error(`${operation}_unsupported_for_ssh_repo`) + } + } + + async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_slug') + return getRepoSlug(repo.path) + } + + async listRepoWorkItems( + repoSelector: string, + limit?: number, + query?: string, + before?: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_items') + return listWorkItems(repo.path, limit, query, before, repo.issueSourcePreference) + } + + async getRepoWorkItem( + repoSelector: string, + number: number, + type?: 'issue' | 'pr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_item') + return getWorkItem(repo.path, number, type) + } + + async getRepoWorkItemByOwnerRepo( + repoSelector: string, + ownerRepo: { owner: string; repo: string }, + number: number, + type: 'issue' | 'pr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_item') + return getWorkItemByOwnerRepo(repo.path, ownerRepo, number, type) + } + + async getRepoWorkItemDetails( + repoSelector: string, + number: number, + type?: 'issue' | 'pr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_item_details') + return getWorkItemDetails(repo.path, number, type) + } + + async countRepoWorkItems(repoSelector: string, query?: string): Promise { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_items') + return countWorkItems(repo.path, query, repo.issueSourcePreference) + } + + async listRepoLabels(repoSelector: string): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_labels') + return listLabels(repo.path, repo.issueSourcePreference) + } + + async listRepoAssignableUsers( + repoSelector: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_assignable_users') + return listAssignableUsers(repo.path, repo.issueSourcePreference) + } + + getGitHubRateLimit(options?: { + force?: boolean + }): Promise>> { + return getRateLimit(options) + } + + async getRepoPRForBranch( + repoSelector: string, + branch: string, + linkedPRNumber?: number | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr') + return getPRForBranch(repo.path, branch, linkedPRNumber ?? null) + } + + async getHostedReviewForBranch(args: { + repoSelector: string + branch: string + linkedGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + }): Promise { + const repo = await this.resolveRepoSelector(args.repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') + const review = await getHostedReviewForBranchFromRepo({ + repoPath: repo.path, + branch: args.branch, + linkedGitHubPR: args.linkedGitHubPR ?? null, + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null + }) + if (review?.provider === 'github' && this.stats && !this.stats.hasCountedPR(review.url)) { + this.stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: review.number, prUrl: review.url } + }) + } + return review + } + + async getRepoIssue( + repoSelector: string, + number: number + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue') + return getIssue(repo.path, number) + } + + async getRepoPRChecks( + repoSelector: string, + prNumber: number, + headSha?: string, + options?: { noCache?: boolean } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_checks') + return getPRChecks(repo.path, prNumber, headSha, options) + } + + async getRepoPRComments( + repoSelector: string, + prNumber: number, + options?: { noCache?: boolean } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_comments') + return getPRComments(repo.path, prNumber, options) + } + + async getRepoPRFileContents( + repoSelector: string, + args: { + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string + } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_file_contents') + return getPRFileContents({ repoPath: repo.path, ...args }) + } + + async resolveRepoReviewThread( + repoSelector: string, + threadId: string, + resolve: boolean + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_review_thread') + return resolveReviewThread(repo.path, threadId, resolve) + } + + async updateRepoPRTitle( + repoSelector: string, + prNumber: number, + title: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_title') + return updatePRTitle(repo.path, prNumber, title) + } + + async mergeRepoPR( + repoSelector: string, + prNumber: number, + method?: 'merge' | 'squash' | 'rebase' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_merge') + return mergePR(repo.path, prNumber, method) + } + + async createRepoIssue( + repoSelector: string, + title: string, + body: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue_create') + return createIssue(repo.path, title, body, repo.issueSourcePreference) + } + + async updateRepoIssue( + repoSelector: string, + number: number, + updates: GitHubIssueUpdate + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue_update') + return updateIssue(repo.path, number, updates) + } + + async addRepoIssueComment( + repoSelector: string, + number: number, + body: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue_comment') + return addIssueComment(repo.path, number, body) + } + + async addRepoPRReviewComment( + repoSelector: string, + args: Omit + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_review_comment') + return addPRReviewComment({ repoPath: repo.path, ...args }) + } + + async addRepoPRReviewCommentReply( + repoSelector: string, + args: { + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_review_comment_reply') + return addPRReviewCommentReply( + repo.path, + args.prNumber, + args.commentId, + args.body, + args.threadId, + args.path, + args.line + ) + } + + async listGitHubProjects(): Promise>> { + return listAccessibleProjects() + } + + async listGitHubLabelsBySlug( + args: ListLabelsBySlugArgs + ): Promise>> { + return listLabelsBySlug(args) + } + + async listGitHubAssignableUsersBySlug( + args: ListAssignableUsersBySlugArgs + ): Promise>> { + return listAssignableUsersBySlug(args) + } + + async listGitHubIssueTypesBySlug( + args: ListIssueTypesBySlugArgs + ): Promise>> { + return listIssueTypesBySlug(args) + } + + async resolveGitHubProjectRef( + args: ResolveProjectRefArgs + ): Promise>> { + return resolveProjectRef(args) + } + + async listGitHubProjectViews( + args: ListProjectViewsArgs + ): Promise>> { + return listProjectViews(args) + } + + async getGitHubProjectViewTable( + args: GetProjectViewTableArgs + ): Promise>> { + return getProjectViewTable(args) + } + + async updateGitHubProjectItemField( + args: UpdateProjectItemFieldArgs + ): Promise>> { + return updateProjectItemFieldValue(args) + } + + async clearGitHubProjectItemField( + args: ClearProjectItemFieldArgs + ): Promise>> { + return clearProjectItemFieldValue(args) + } + + async updateGitHubIssueBySlug( + args: UpdateIssueBySlugArgs + ): Promise>> { + return updateIssueBySlug(args) + } + + async updateGitHubPullRequestBySlug( + args: UpdatePullRequestBySlugArgs + ): Promise>> { + return updatePullRequestBySlug(args) + } + + async updateGitHubIssueTypeBySlug( + args: UpdateIssueTypeBySlugArgs + ): Promise>> { + return updateIssueTypeBySlug(args) + } + + async addGitHubIssueCommentBySlug( + args: AddIssueCommentBySlugArgs + ): Promise>> { + return addIssueCommentBySlug(args) + } + + async updateGitHubIssueCommentBySlug( + args: UpdateIssueCommentBySlugArgs + ): Promise>> { + return updateIssueCommentBySlug(args) + } + + async deleteGitHubIssueCommentBySlug( + args: DeleteIssueCommentBySlugArgs + ): Promise>> { + return deleteIssueCommentBySlug(args) + } + async getRepoHooks(repoSelector: string) { const repo = await this.resolveRepoSelector(repoSelector) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + hasHooksFile: false, + hooks: null, + setupRunPolicy: getEffectiveSetupRunPolicy(repo), + source: null + } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, '.orca.yaml')) + const hooks = result.isBinary ? null : parseOrcaYaml(result.content) + return { + hasHooksFile: Boolean(hooks), + hooks, + setupRunPolicy: getEffectiveSetupRunPolicy(repo), + source: hooks ? 'orca.yaml' : null + } + } catch { + return { + hasHooksFile: false, + hooks: null, + setupRunPolicy: getEffectiveSetupRunPolicy(repo), + source: null + } + } + } const hasFile = hasHooksFile(repo.path) const hooks = getEffectiveHooks(repo) const setupRunPolicy = getEffectiveSetupRunPolicy(repo) @@ -3946,6 +4741,164 @@ export class OrcaRuntimeService { } } + async checkRepoHooks(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, '.orca.yaml')) + if (result.isBinary) { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + const { parse } = await import('yaml') + const parsed = parse(result.content) + return { hasHooks: true, hooks: parsed, mayNeedUpdate: false } + } catch { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + } + + const has = hasHooksFile(repo.path) + const hooks = has ? loadHooks(repo.path) : null + return { + hasHooks: has, + hooks, + mayNeedUpdate: has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) + } + } + + async readRepoIssueCommand(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' as const + } + } + + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: issueCommandPath, + source: 'none' as const + } + } + const localContent = await this.readRemoteIssueCommandOverride(fsProvider, issueCommandPath) + const sharedContent = await this.readRemoteSharedIssueCommand(fsProvider, repo.path) + const effectiveContent = localContent ?? sharedContent + return { + localContent, + sharedContent, + effectiveContent, + localFilePath: issueCommandPath, + source: localContent + ? ('local' as const) + : sharedContent + ? ('shared' as const) + : ('none' as const) + } + } + + return readIssueCommand(repo.path) + } + + private async readRemoteIssueCommandOverride( + fsProvider: IFilesystemProvider, + issueCommandPath: string + ): Promise { + try { + const result = await fsProvider.readFile(issueCommandPath) + if (result.isBinary) { + return null + } + return result.content.trim() || null + } catch { + return null + } + } + + private async readRemoteSharedIssueCommand( + fsProvider: IFilesystemProvider, + repoPath: string + ): Promise { + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, 'orca.yaml')) + if (result.isBinary) { + return null + } + return parseOrcaYaml(result.content)?.issueCommand?.trim() || null + } catch { + return null + } + } + + async writeRepoIssueCommand(repoSelector: string, content: string): Promise<{ ok: true }> { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { ok: true } + } + + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { ok: true } + } + const trimmed = content.trim() + if (!trimmed) { + await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { + if (!isENOENT(error)) { + throw error + } + }) + return { ok: true } + } + await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) + await this.ensureRemoteOrcaDirIgnored(fsProvider, repo.path) + await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) + return { ok: true } + } + + writeIssueCommand(repo.path, content) + return { ok: true } + } + + private async ensureRemoteOrcaDirIgnored( + fsProvider: IFilesystemProvider, + repoPath: string + ): Promise { + const gitignorePath = joinWorktreeRelativePath(repoPath, '.gitignore') + try { + const result = await fsProvider.readFile(gitignorePath) + if (result.isBinary || /^\.orca\/?$/m.test(result.content)) { + return + } + const separator = result.content.endsWith('\n') ? '' : '\n' + await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) + } catch { + try { + await fsProvider.writeFile(gitignorePath, '.orca\n') + } catch (error) { + console.warn('[runtime] Could not update remote .gitignore to exclude .orca', error) + } + } + } + async listNotes(args: { worktreeSelector: string; limit?: number }): Promise { const scope = await this.resolveNotesScope(args.worktreeSelector) return await this.getNotesStore().list(this.getNotesScope(scope.projectId), { @@ -3982,6 +4935,53 @@ export class OrcaRuntimeService { }) } + async saveNote(args: { + worktreeSelector: string + note: string + title?: string + bodyMarkdown: string + revision?: number + makeActive?: boolean + updatedBySessionId?: string | null + }): Promise { + const scope = await this.resolveNotesScope(args.worktreeSelector) + return await this.getNotesStore().save(this.getNotesScope(scope.projectId), { + projectId: scope.projectId, + worktreeId: scope.worktreeId, + note: args.note, + title: args.title, + bodyMarkdown: args.bodyMarkdown, + revision: args.revision, + makeActive: args.makeActive, + updatedBySessionId: args.updatedBySessionId + }) + } + + async renameNote(args: { + worktreeSelector: string + note: string + title: string + updatedBySessionId?: string | null + }): Promise { + const scope = await this.resolveNotesScope(args.worktreeSelector) + return await this.getNotesStore().rename(this.getNotesScope(scope.projectId), { + projectId: scope.projectId, + worktreeId: scope.worktreeId, + note: args.note, + title: args.title, + updatedBySessionId: args.updatedBySessionId + }) + } + + async deleteNote(args: { worktreeSelector: string; note: string }): Promise { + const scope = await this.resolveNotesScope(args.worktreeSelector) + return await this.getNotesStore().delete(this.getNotesScope(scope.projectId), { + projectId: scope.projectId, + worktreeId: scope.worktreeId, + note: args.note + }) + } + async appendNote(args: { worktreeSelector: string note: string @@ -4014,6 +5014,30 @@ export class OrcaRuntimeService { }) } + async linkNote(args: { + worktreeSelector: string + note: string + kind: NoteLinkKind + }): Promise { + const scope = await this.resolveNotesScope(args.worktreeSelector) + return await this.getNotesStore().setLink(this.getNotesScope(scope.projectId), { + projectId: scope.projectId, + worktreeId: scope.worktreeId, + note: args.note, + kind: args.kind + }) + } + + async resolveNotesPanelOpenStateForWorktree(args: { + worktreeSelector: string + }): Promise { + const scope = await this.resolveNotesScope(args.worktreeSelector) + return await this.getNotesStore().resolvePanelOpenState(this.getNotesScope(scope.projectId), { + projectId: scope.projectId, + worktreeId: scope.worktreeId + }) + } + async listProjectNotes(args: NoteListArgs): Promise { this.assertKnownNotesProject(args.projectId) return await this.getNotesStore().list(this.getNotesScope(args.projectId), args) @@ -4127,10 +5151,15 @@ export class OrcaRuntimeService { name: string baseBranch?: string linkedIssue?: number | null + linkedPR?: number | null comment?: string + displayName?: string + sparseCheckout?: { directories: string[]; presetId?: string } + pushTarget?: GitPushTarget runHooks?: boolean activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' + createdWithAgent?: TuiAgent startup?: WorktreeStartupLaunch }): Promise { if (!this.store) { @@ -4141,8 +5170,15 @@ export class OrcaRuntimeService { if (isFolderRepo(repo)) { throw new Error('Folder mode does not support creating worktrees.') } + if (repo.connectionId) { + // Why: SSH-backed worktree creation still relies on the desktop SSH + // flow, which can prime relay roots and enforce its remote constraints. + // Runtime RPC must not fall through to local git against server paths. + throw new Error('SSH-backed worktree creation is not supported through runtime RPC yet.') + } const settings = this.store.getSettings() const requestedName = args.name + const requestedDisplayName = args.displayName?.trim() || undefined const sanitizedName = sanitizeWorktreeName(args.name) const username = getGitUsername(repo.path) const branchName = computeBranchName(sanitizedName, settings, username) @@ -4200,13 +5236,47 @@ export class OrcaRuntimeService { // if future refactors change that contract. } - await addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) + const sparseDirectories = args.sparseCheckout + ? normalizeSparseDirectories(args.sparseCheckout.directories) + : [] + if (args.sparseCheckout && sparseDirectories.length === 0) { + throw new Error('Sparse checkout requires at least one repo-relative directory.') + } + + let preparedPushTarget: GitPushTarget | undefined + if (args.pushTarget) { + // Why: fork-PR worktrees created through a remote runtime need the same + // upstream target setup as local desktop creates, or Push would publish + // to the wrong remote after the client/server split. + preparedPushTarget = await prepareWorktreePushTarget(repo.path, args.pushTarget) + } + + await (sparseDirectories.length > 0 + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + )) + + let configuredPushTarget: GitPushTarget | undefined + if (preparedPushTarget) { + configuredPushTarget = await configureCreatedWorktreePushTarget( + worktreePath, + branchName, + preparedPushTarget + ) + } + const gitWorktrees = await listWorktrees(repo.path) const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath)) if (!created) { @@ -4215,6 +5285,11 @@ export class OrcaRuntimeService { const worktreeId = `${repo.id}::${created.path}` const now = Date.now() + const displayNameMeta = requestedDisplayName + ? { displayName: requestedDisplayName } + : shouldSetDisplayName(requestedName, branchName, sanitizedName) + ? { displayName: requestedName } + : {} const meta = this.store.setWorktreeMeta(worktreeId, { lastActivityAt: now, // See createRemoteWorktree: createdAt grants the new worktree a grace @@ -4222,15 +5297,31 @@ export class OrcaRuntimeService { // push it down before the user has had a chance to notice it. Smart-sort // uses max(lastActivityAt, createdAt + CREATE_GRACE_MS). createdAt: now, - ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) - ? { displayName: requestedName } - : {}), + ...displayNameMeta, baseRef: baseBranch, + ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), + ...(sparseDirectories.length > 0 + ? { + sparseDirectories, + sparseBaseRef: baseBranch, + sparsePresetId: args.sparseCheckout?.presetId + } + : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.comment !== undefined ? { comment: args.comment } : {}) }) const worktree = mergeWorktree(repo.id, created, meta) + if ( + settings.experimentalWorktreeSymlinks && + repo.symlinkPaths && + repo.symlinkPaths.length > 0 + ) { + await createWorktreeSymlinks(repo.path, created.path, repo.symlinkPaths) + } + let setup: CreateWorktreeResult['setup'] let warning: string | undefined // Why: CLI-created worktrees do not have a renderer preview to mismatch @@ -4277,6 +5368,7 @@ export class OrcaRuntimeService { // are not recognized and all git operations fail with "Access denied: // unknown repository or worktree path". invalidateAuthorizedRootsCache() + this.notifier?.worktreesChanged(repo.id) const shouldActivate = args.activate === true || args.runHooks === true let didSpawnStartup = false @@ -4669,6 +5761,12 @@ export class OrcaRuntimeService { if (!repo) { return null } + if (repo.connectionId) { + // Why: the drift probe uses local git helpers. Until the SSH provider + // exposes equivalent remote refs/log plumbing, fail closed to "unknown" + // instead of probing a server path on the desktop filesystem. + return null + } const meta = this.store.getWorktreeMeta(wt.id) const base = meta?.baseRef || meta?.sparseBaseRef || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) @@ -4694,25 +5792,12 @@ export class OrcaRuntimeService { return { base, behind: drift.behind, recentSubjects } } - async updateManagedWorktreeMeta( - worktreeSelector: string, - updates: { - displayName?: string - linkedIssue?: number | null - comment?: string - isPinned?: boolean - } - ) { + async updateManagedWorktreeMeta(worktreeSelector: string, updates: Partial) { if (!this.store) { throw new Error('runtime_unavailable') } const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const meta = this.store.setWorktreeMeta(worktree.id, { - ...(updates.displayName !== undefined ? { displayName: updates.displayName } : {}), - ...(updates.linkedIssue !== undefined ? { linkedIssue: updates.linkedIssue } : {}), - ...(updates.comment !== undefined ? { comment: updates.comment } : {}), - ...(updates.isPinned !== undefined ? { isPinned: updates.isPinned } : {}) - }) + const meta = this.store.setWorktreeMeta(worktree.id, omitUndefinedProperties(updates)) // Why: unlike renderer-initiated optimistic updates, CLI callers need an // explicit push so the editor refreshes metadata changed outside the UI. this.invalidateResolvedWorktreeCache() @@ -4720,6 +5805,127 @@ export class OrcaRuntimeService { return mergeWorktree(worktree.repoId, worktree.git, meta) } + persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const now = Date.now() + let updated = 0 + for (let i = 0; i < orderedIds.length; i++) { + this.store.setWorktreeMeta(orderedIds[i], { sortOrder: now - i * 1000 }) + updated++ + } + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return { updated } + } + + async resolveManagedPrBase(args: { + repoId: string + prNumber: number + headRefName?: string + isCrossRepository?: boolean + }): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const repo = this.store.getRepo(args.repoId) + if (!repo) { + return { error: 'Repo not found' } + } + if (repo.connectionId) { + return { error: 'PR start points are not supported for remote repos yet.' } + } + if (isFolderRepo(repo)) { + return { error: 'Folder mode does not support creating worktrees.' } + } + + let headRefName = args.headRefName?.trim() ?? '' + let isCrossRepository = args.isCrossRepository === true + let pushTarget: GitPushTarget | undefined + + if (!headRefName) { + const item = await getWorkItem(repo.path, args.prNumber, 'pr') + if (!item || item.type !== 'pr') { + return { error: `PR #${args.prNumber} not found.` } + } + headRefName = (item.branchName ?? '').trim() + if (!headRefName) { + return { error: `PR #${args.prNumber} has no head branch.` } + } + if (item.isCrossRepository === true) { + isCrossRepository = true + } + } + + if (isCrossRepository) { + try { + pushTarget = (await getPullRequestPushTarget(repo.path, args.prNumber)) ?? undefined + } catch (error) { + return { + error: + error instanceof Error + ? error.message + : `Could not resolve PR #${args.prNumber} head push target.` + } + } + if (!pushTarget) { + return { error: `Could not resolve PR #${args.prNumber} head push target.` } + } + } + + let remote: string + try { + remote = await getDefaultRemote(repo.path) + } catch (error) { + return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } + } + + if (isCrossRepository) { + const pullRef = `refs/pull/${args.prNumber}/head` + try { + await gitExecFileAsync(['fetch', remote, pullRef], { cwd: repo.path }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to fetch ${pullRef}: ${message.split('\n')[0]}` } + } + try { + const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', 'FETCH_HEAD'], { + cwd: repo.path + }) + const sha = stdout.trim() + if (!sha) { + return { error: `Empty SHA resolving fork PR #${args.prNumber} head.` } + } + return { baseBranch: sha, ...(pushTarget ? { pushTarget } : {}) } + } catch { + return { error: `Could not resolve fork PR #${args.prNumber} head after fetch.` } + } + } + + try { + await gitExecFileAsync( + ['fetch', remote, `+refs/heads/${headRefName}:refs/remotes/${remote}/${headRefName}`], + { cwd: repo.path } + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to fetch ${remote}/${headRefName}: ${message.split('\n')[0]}` } + } + + const remoteRef = `${remote}/${headRefName}` + try { + await gitExecFileAsync(['rev-parse', '--verify', remoteRef], { cwd: repo.path }) + } catch { + return { error: `Remote ref ${remoteRef} does not exist after fetch.` } + } + + return { + baseBranch: remoteRef, + pushTarget: pushTarget ?? { remoteName: remote, branchName: headRefName } + } + } + async removeManagedWorktree( worktreeSelector: string, force = false, @@ -4736,6 +5942,19 @@ export class OrcaRuntimeService { if (isFolderRepo(repo)) { throw new Error('Folder mode does not support deleting worktrees.') } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${repo.connectionId}"`) + } + await provider.removeWorktree(worktree.path, force) + this.clearOptimisticReconcileToken(worktree.id) + this.store.removeWorktreeMeta(worktree.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifier?.worktreesChanged(repo.id) + return {} + } // Why: kill every PTY belonging to this worktree BEFORE the git-level // removal. Some shells keep the worktree directory busy, and `git worktree @@ -4967,7 +6186,14 @@ export class OrcaRuntimeService { afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id } - const win = this.getAuthoritativeWindow() + const win = this.getAvailableAuthoritativeWindow() + if (!win) { + return await this.createHeadlessMobileSessionTerminal( + worktreeId, + opts.activate !== false, + opts.afterTabId + ) + } const requestId = randomUUID() const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { const timer = setTimeout(() => { @@ -5004,6 +6230,64 @@ export class OrcaRuntimeService { return await this.waitForMobileTerminalSurface(worktreeId, reply.tabId) } + private async createHeadlessMobileSessionTerminal( + worktreeId: string, + activate: boolean, + afterTabId?: string + ): Promise { + const terminal = await this.createTerminal(`id:${worktreeId}`, { focus: false }) + const livePty = this.getLivePtyForHandle(terminal.handle) + if (!livePty) { + throw new Error('terminal_handle_stale') + } + const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` + const leafId = `pane:${FIRST_PANE_ID}` + const tab: RuntimeMobileSessionTerminalTab = { + type: 'terminal', + id: `${parentTabId}::${leafId}`, + parentTabId, + leafId, + title: terminal.title ?? livePty.pty.title ?? 'Terminal', + isActive: activate + } + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const tabs = (existing?.tabs ?? []) + .filter((candidate) => candidate.id !== tab.id) + .map((candidate) => ({ + ...candidate, + isActive: activate ? false : candidate.isActive + })) + const insertAfter = afterTabId ? tabs.findIndex((candidate) => candidate.id === afterTabId) : -1 + if (insertAfter >= 0) { + tabs.splice(insertAfter + 1, 0, tab) + } else { + tabs.push(tab) + } + const next: RuntimeMobileSessionTabsSnapshot = { + worktree: worktreeId, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, + activeGroupId: existing?.activeGroupId ?? null, + activeTabId: activate ? tab.id : (existing?.activeTabId ?? null), + activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null), + tabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, next) + const result = this.toMobileSessionTabsResult(next) + for (const listener of this.mobileSessionTabListeners) { + listener(result) + } + const created = result.tabs.find((candidate) => candidate.id === tab.id) + if (!created || created.type !== 'terminal') { + throw new Error('terminal_handle_stale') + } + return { + tab: created, + publicationEpoch: result.publicationEpoch, + snapshotVersion: result.snapshotVersion + } + } + private waitForMobileTerminalSurface( worktreeId: string, parentTabId: string, @@ -5392,7 +6676,9 @@ export class OrcaRuntimeService { if (selector.startsWith('id:')) { candidates = worktrees.filter((worktree) => worktree.id === selector.slice(3)) } else if (selector.startsWith('path:')) { - candidates = worktrees.filter((worktree) => worktree.path === selector.slice(5)) + candidates = worktrees.filter((worktree) => + runtimePathsEqual(worktree.path, selector.slice(5)) + ) } else if (selector.startsWith('branch:')) { const branchSelector = selector.slice(7) candidates = worktrees.filter((worktree) => @@ -5407,7 +6693,7 @@ export class OrcaRuntimeService { candidates = worktrees.filter( (worktree) => worktree.id === selector || - worktree.path === selector || + runtimePathsEqual(worktree.path, selector) || branchSelectorMatches(worktree.branch, selector) ) } @@ -5467,12 +6753,15 @@ export class OrcaRuntimeService { if (selector.startsWith('id:')) { candidates = repos.filter((repo) => repo.id === selector.slice(3)) } else if (selector.startsWith('path:')) { - candidates = repos.filter((repo) => repo.path === selector.slice(5)) + candidates = repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5))) } else if (selector.startsWith('name:')) { candidates = repos.filter((repo) => repo.displayName === selector.slice(5)) } else { candidates = repos.filter( - (repo) => repo.id === selector || repo.path === selector || repo.displayName === selector + (repo) => + repo.id === selector || + runtimePathsEqual(repo.path, selector) || + repo.displayName === selector ) } @@ -5485,6 +6774,13 @@ export class OrcaRuntimeService { throw new Error('repo_not_found') } + private requireStore(): Store { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return this.store as unknown as Store + } + private async listResolvedWorktrees(): Promise { if (!this.store) { return [] @@ -5493,19 +6789,23 @@ export class OrcaRuntimeService { if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) { return this.resolvedWorktreeCache.worktrees } - if (this.resolvedWorktreeInFlight) { - return this.resolvedWorktreeInFlight + const generation = this.resolvedWorktreeGeneration + if (this.resolvedWorktreeInFlight?.generation === generation) { + return this.resolvedWorktreeInFlight.promise } - this.resolvedWorktreeInFlight = this.computeResolvedWorktrees() + const promise = this.computeResolvedWorktrees(generation) + this.resolvedWorktreeInFlight = { generation, promise } try { - return await this.resolvedWorktreeInFlight + return await promise } finally { - this.resolvedWorktreeInFlight = null + if (this.resolvedWorktreeInFlight?.promise === promise) { + this.resolvedWorktreeInFlight = null + } } } - private async computeResolvedWorktrees(): Promise { + private async computeResolvedWorktrees(generation: number): Promise { if (!this.store) { return [] } @@ -5524,11 +6824,7 @@ export class OrcaRuntimeService { const worktreeId = `${repo.id}::${gitWorktree.path}` const merged = mergeWorktree(repo.id, gitWorktree, metaById[worktreeId], repo.displayName) return { - id: merged.id, - repoId: repo.id, - path: merged.path, - branch: merged.branch, - linkedIssue: metaById[worktreeId]?.linkedIssue ?? null, + ...merged, git: { path: gitWorktree.path, head: gitWorktree.head, @@ -5546,9 +6842,11 @@ export class OrcaRuntimeService { // Why: terminal polling can be frequent, but git worktree state is still // allowed to change outside Orca. A short TTL avoids shelling out on every // read without pretending the cache is authoritative for long. - this.resolvedWorktreeCache = { - worktrees, - expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS + if (generation === this.resolvedWorktreeGeneration) { + this.resolvedWorktreeCache = { + worktrees, + expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS + } } return worktrees } @@ -5558,6 +6856,7 @@ export class OrcaRuntimeService { } private invalidateResolvedWorktreeCache(): void { + this.resolvedWorktreeGeneration += 1 this.resolvedWorktreeCache = null } @@ -5800,16 +7099,19 @@ export class OrcaRuntimeService { } const syncedTab = this.tabs.get(tab.parentTabId) const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null + const pty = leaf ? null : this.findPtyForMobileTerminalTab(tab) tabs.push({ type: 'terminal', id: tab.id, parentTabId: tab.parentTabId, leafId: tab.leafId, - title: leaf?.paneTitle ?? syncedTab?.title ?? tab.title, + title: leaf?.paneTitle ?? syncedTab?.title ?? pty?.title ?? tab.title, isActive: tab.isActive, ...(leaf ? { status: 'ready' as const, terminal: this.issueHandle(leaf) } - : { status: 'pending-handle' as const, terminal: null }) + : pty + ? { status: 'ready' as const, terminal: this.issuePtyHandle(pty) } + : { status: 'pending-handle' as const, terminal: null }) }) } const active = tabs.find((tab) => tab.isActive) ?? null @@ -5824,6 +7126,21 @@ export class OrcaRuntimeService { } } + private findPtyForMobileTerminalTab( + tab: RuntimeMobileSessionTerminalTab + ): RuntimePtyWorktreeRecord | null { + const paneKeys = new Set([`${tab.parentTabId}:${tab.leafId}`]) + if (tab.leafId === `pane:${FIRST_PANE_ID}`) { + paneKeys.add(`${tab.parentTabId}:${FIRST_PANE_ID}`) + } + for (const pty of this.ptysById.values()) { + if (pty.tabId === tab.parentTabId && pty.paneKey && paneKeys.has(pty.paneKey)) { + return pty + } + } + return null + } + // Why: group address resolution (Section 4.5) needs to query per-handle agent // status without throwing on stale handles, so this returns null on any error. getAgentStatusForHandle(handle: string): string | null { @@ -6418,1318 +7735,355 @@ export class OrcaRuntimeService { return `${tabId}::${leafId}` } + // ── Linear integration ── + + linearConnect(apiKey: string): ReturnType { + return connectLinear(apiKey) + } + + linearDisconnect(): { ok: true } { + disconnectLinear() + return { ok: true } + } + + linearStatus(): ReturnType { + return getLinearStatus() + } + + linearTestConnection(): ReturnType { + return testLinearConnection() + } + + linearSearchIssues(query: string, limit = 20): ReturnType { + return searchLinearIssues(query, Math.min(Math.max(1, limit), 50)) + } + + linearListIssues(filter?: LinearListFilter, limit = 20): ReturnType { + return listLinearIssues(filter, Math.min(Math.max(1, limit), 50)) + } + + linearCreateIssue( + teamId: string, + title: string, + description?: string + ): ReturnType { + return createLinearIssue(teamId, title, description) + } + + linearGetIssue(id: string): ReturnType { + return getLinearIssue(id) + } + + linearUpdateIssue(id: string, updates: LinearIssueUpdate): ReturnType { + return updateLinearIssue(id, updates) + } + + linearAddIssueComment(issueId: string, body: string): ReturnType { + return addLinearIssueComment(issueId, body) + } + + linearIssueComments(issueId: string): ReturnType { + return getLinearIssueComments(issueId) + } + + linearListTeams(): ReturnType { + return listLinearTeams() + } + + linearTeamStates(teamId: string): ReturnType { + return getLinearTeamStates(teamId) + } + + linearTeamLabels(teamId: string): ReturnType { + return getLinearTeamLabels(teamId) + } + + linearTeamMembers(teamId: string): ReturnType { + return getLinearTeamMembers(teamId) + } + // ── Browser automation ── - private requireAgentBrowserBridge(): AgentBrowserBridge { - if (!this.agentBrowserBridge) { - throw new BrowserError('browser_no_tab', 'No browser session is active') - } - return this.agentBrowserBridge - } - - // Why: the CLI sends worktree selectors (e.g. "path:/Users/...") but the - // bridge stores worktreeIds in "repoId::path" format (from the renderer's - // Zustand store). This helper resolves the selector to the store-compatible - // ID so the bridge can filter tabs correctly. - private async resolveBrowserWorktreeId(selector?: string): Promise { - if (!selector) { - // Why: after app restart, webviews only mount when the browser pane is visible. - // Without --worktree, we still need to activate the view so persisted tabs - // become operable via registerGuest. - const bridge = this.agentBrowserBridge - if (bridge && bridge.getRegisteredTabs().size === 0) { - try { - const win = this.getAuthoritativeWindow() - win.webContents.send('browser:activateView', {}) - await new Promise((resolve) => setTimeout(resolve, 500)) - } catch { - // Window may not exist yet (e.g. during startup or in tests) - } - } - return undefined - } - - const worktreeId = (await this.resolveWorktreeSelector(selector)).id - // Why: explicit worktree selectors are user intent, so resolution errors - // must surface instead of silently widening browser routing scope. Only the - // activation step remains best-effort because missing windows during tests - // or startup should not erase the validated worktree target itself. - const bridge = this.agentBrowserBridge - if (bridge && bridge.getRegisteredTabs(worktreeId).size === 0) { - try { - await this.ensureBrowserWorktreeActive(worktreeId) - } catch { - // Fall through with the validated worktree id so downstream routing - // still stays scoped to the caller's explicit selector. - } - } - return worktreeId - } - - private async resolveBrowserCommandTarget( - params: BrowserCommandTargetParams - ): Promise { - const browserPageId = - typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined - if (!browserPageId) { - return { - worktreeId: await this.resolveBrowserWorktreeId(params.worktree) - } - } - - return { - // Why: explicit browserPageId is already a stable tab identity, so we do - // not auto-resolve cwd worktree scoping on top of it. Only honor an - // explicit --worktree when the caller asked for that extra validation. - worktreeId: params.worktree - ? await this.resolveBrowserWorktreeId(params.worktree) - : undefined, - browserPageId - } - } - - // Why: browser tabs only mount (and become operable) when their worktree is - // the active worktree in the renderer AND activeTabType is 'browser'. If either - // condition is false, the webview stays in display:none and Electron won't start - // its guest process — dom-ready never fires, registerGuest never runs, and CLI - // browser commands fail with "CDP connection refused". - private async ensureBrowserWorktreeActive(worktreeId: string): Promise { - const win = this.getAuthoritativeWindow() - const repoId = getRepoIdFromWorktreeId(worktreeId) - if (!repoId) { - return - } - win.webContents.send('ui:activateWorktree', { repoId, worktreeId }) - // Why: switching worktree alone sets activeView='terminal'. Browser webviews - // won't mount until activeTabType is 'browser'. Send a second IPC to flip it. - win.webContents.send('browser:activateView', { worktreeId }) - // Why: give the renderer time to mount the webview after switching worktrees. - // The webview needs to attach and fire dom-ready before registerGuest runs. - await new Promise((resolve) => setTimeout(resolve, 500)) - } - - // Why: agent-browser drives navigation via CDP, which bypasses Electron's - // webview event system. The renderer's did-navigate / page-title-updated - // listeners never fire, leaving the Zustand store (and thus the Orca UI's - // address bar and tab title) stale. Push updates from main → renderer after - // any navigation-causing command so the UI stays in sync. - private notifyRendererNavigation(browserPageId: string, url: string, title: string): void { - try { - const win = this.getAuthoritativeWindow() - win.webContents.send('browser:navigation-update', { browserPageId, url, title }) - } catch { - // Window may not exist during shutdown - } - } - - // Why: `tabSwitch` only flips the bridge's `activeWebContentsId` — it - // does not surface the browser pane in the renderer. Without --focus, the - // switch is invisible to the user. With --focus, we send a dedicated IPC - // so the renderer can update its per-worktree active-tab state. - // - // Why this IPC carries `worktreeId` instead of letting the renderer - // dispatch `setActiveWorktree`: multiple agents drive browsers in parallel - // worktrees. A global focus call from agent X would steal the user's - // screen from agent Y's worktree. The renderer-side handler - // (focusBrowserTabInWorktree) updates per-worktree state unconditionally - // and only flips globals when the user is already on the targeted - // worktree. Cross-worktree --focus calls pre-stage silently. - private notifyRendererBrowserPaneFocus( - worktreeId: string | undefined, - browserPageId: string - ): void { - try { - const win = this.getAuthoritativeWindow() - win.webContents.send('browser:pane-focus', { - worktreeId: worktreeId ?? null, - browserPageId - }) - } catch { - // Window may not exist during shutdown - } - } - - async browserSnapshot(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().snapshot(target.worktreeId, target.browserPageId) - } - - async browserClick( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.click(params.element, target.worktreeId, target.browserPageId) - // Why: clicks can trigger navigation (e.g. submitting a form, clicking a link). - // Read the target tab's live URL/title after the click and push to the - // renderer so the UI updates even when automation targeted a non-active page. - const page = bridge.getPageInfo(target.worktreeId, target.browserPageId) - if (page) { - this.notifyRendererNavigation(page.browserPageId, page.url, page.title) - } - return result - } - - async browserGoto( - params: { url: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserFill( - params: { - element: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().fill( - params.element, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserType( - params: { input: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().type( - params.input, - target.worktreeId, - target.browserPageId - ) - } - - async browserSelect( - params: { - element: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().select( - params.element, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserScroll( - params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().scroll( - params.direction, - params.amount, - target.worktreeId, - target.browserPageId - ) - } - - async browserBack(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.back(target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserReload(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.reload(target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserScreenshot( - params: { - format?: 'png' | 'jpeg' - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().screenshot( - params.format, - target.worktreeId, - target.browserPageId - ) - } - - async browserEval( - params: { expression: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().evaluate( - params.expression, - target.worktreeId, - target.browserPageId - ) - } - - async browserTabList(params: { worktree?: string }): Promise { - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - const result = this.requireAgentBrowserBridge().tabList(worktreeId) - return { - tabs: result.tabs.map((tab) => this.enrichBrowserTabInfo(tab)) - } - } - - async browserTabShow(params: { page: string; worktree?: string }): Promise { - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - return { tab: this.describeBrowserTab(params.page, worktreeId) } - } - - async browserTabCurrent(params: { worktree?: string }): Promise { - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - const browserPageId = this.requireAgentBrowserBridge().getActivePageId(worktreeId) - if (!browserPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - return { tab: this.describeBrowserTab(browserPageId, worktreeId) } - } - - async browserTabSwitch( - params: { - index?: number - focus?: boolean - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.tabSwitch(params.index, target.worktreeId, target.browserPageId) - if (params.focus) { - // Why: prefer the explicit --worktree the caller passed; fall back to - // the bridge's owning-worktree map for the just-switched tab. The - // owning worktree is what the renderer needs to scope the focus to. - // The renderer NEVER yanks the user across worktrees on this signal - // (see focusBrowserTabInWorktree). - const worktreeId = - target.worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined - this.notifyRendererBrowserPaneFocus(worktreeId, result.browserPageId) - } - return result - } - - async browserHover( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().hover( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserDrag( - params: { - from: string - to: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().drag( - params.from, - params.to, - target.worktreeId, - target.browserPageId - ) - } - - async browserUpload( - params: { element: string; files: string[] } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().upload( - params.element, - params.files, - target.worktreeId, - target.browserPageId - ) - } - - async browserWait( - params: { - selector?: string - timeout?: number - text?: string - url?: string - load?: string - fn?: string - state?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const { worktree: _, page: __, ...options } = params - return this.requireAgentBrowserBridge().wait(options, target.worktreeId, target.browserPageId) - } - - async browserCheck( - params: { element: string; checked: boolean } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().check( - params.element, - params.checked, - target.worktreeId, - target.browserPageId - ) - } - - async browserFocus( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().focus( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserClear( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clear( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserSelectAll( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().selectAll( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserKeypress( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().keypress( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserPdf(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId) - } - - async browserFullScreenshot( - params: { - format?: 'png' | 'jpeg' - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().fullPageScreenshot( - params.format, - target.worktreeId, - target.browserPageId - ) - } - - // ── Cookie management ── - - async browserCookieGet( - params: { url?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieGet( - params.url, - target.worktreeId, - target.browserPageId - ) - } - - async browserCookieSet( - params: { - name: string - value: string - domain?: string - path?: string - secure?: boolean - httpOnly?: boolean - sameSite?: string - expires?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieSet( - params, - target.worktreeId, - target.browserPageId - ) - } - - async browserCookieDelete( - params: { - name: string - domain?: string - url?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieDelete( - params.name, - params.domain, - params.url, - target.worktreeId, - target.browserPageId - ) - } - - // ── Viewport ── - - async browserSetViewport( - params: { - width: number - height: number - deviceScaleFactor?: number - mobile?: boolean - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setViewport( - params.width, - params.height, - params.deviceScaleFactor, - params.mobile, - target.worktreeId, - target.browserPageId - ) - } - - // ── Geolocation ── - - async browserSetGeolocation( - params: { - latitude: number - longitude: number - accuracy?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setGeolocation( - params.latitude, - params.longitude, - params.accuracy, - target.worktreeId, - target.browserPageId - ) - } - - // ── Request interception ── - - async browserInterceptEnable( - params: { - patterns?: string[] - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptEnable( - params.patterns, - target.worktreeId, - target.browserPageId - ) - } - - async browserInterceptDisable( - params: BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptDisable( - target.worktreeId, - target.browserPageId - ) - } - - async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptList(target.worktreeId, target.browserPageId) - } - - // ── Console/network capture ── - - async browserCaptureStart( - params: BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId) - } - - async browserCaptureStop(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId) - } - - async browserConsoleLog( - params: { limit?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().consoleLog( - params.limit, - target.worktreeId, - target.browserPageId - ) - } - - async browserNetworkLog( - params: { limit?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().networkLog( - params.limit, - target.worktreeId, - target.browserPageId - ) - } - - // ── Additional core commands ── - - async browserDblclick( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dblclick( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserForward(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId) - } - - async browserScrollIntoView( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().scrollIntoView( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserGet( - params: { - what: string - selector?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().get( - params.what, - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - async browserIs( - params: { what: string; selector: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().is( - params.what, - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - // ── Keyboard insert text ── - - async browserKeyboardInsertText( - params: { text: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().keyboardInsertText( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Mouse commands ── - - async browserMouseMove( - params: { x: number; y: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseMove( - params.x, - params.y, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseDown( - params: { button?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseDown( - params.button, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseUp( - params.button, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseWheel( - params: { - dy: number - dx?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseWheel( - params.dy, - params.dx, - target.worktreeId, - target.browserPageId - ) - } - - // ── Find (semantic locators) ── - - async browserFind( - params: { - locator: string - value: string - action: string - text?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().find( - params.locator, - params.value, - params.action, - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Set commands ── - - async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setDevice( - params.name, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetOffline( - params: { state?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setOffline( - params.state, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetHeaders( - params: { headers: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setHeaders( - params.headers, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetCredentials( - params: { - user: string - pass: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setCredentials( - params.user, - params.pass, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetMedia( - params: { - colorScheme?: string - reducedMotion?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setMedia( - params.colorScheme, - params.reducedMotion, - target.worktreeId, - target.browserPageId - ) - } - - // ── Clipboard commands ── - - async browserClipboardRead(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId) - } - - async browserClipboardWrite( - params: { text: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clipboardWrite( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Dialog commands ── - - async browserDialogAccept( - params: { text?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dialogAccept( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId) - } - - // ── Storage commands ── - - async browserStorageLocalGet( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalGet( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageLocalSet( - params: { - key: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalSet( - params.key, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalClear( - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionGet( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionGet( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionSet( - params: { - key: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionSet( - params.key, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionClear( - target.worktreeId, - target.browserPageId - ) - } - - // ── Download command ── - - async browserDownload( - params: { - selector: string - path: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().download( - params.selector, - params.path, - target.worktreeId, - target.browserPageId - ) - } - - // ── Highlight command ── - - async browserHighlight( - params: { selector: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().highlight( - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - // ── New: exec passthrough + tab lifecycle ── - - async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().exec( - params.command, - target.worktreeId, - target.browserPageId - ) - } - - async browserTabCreate(params: { - url?: string - worktree?: string - profileId?: string - }): Promise<{ browserPageId: string }> { - const url = params.url ?? 'about:blank' - const worktreeId = params.worktree - ? (await this.resolveWorktreeSelector(params.worktree)).id - : undefined - const { browserPageId } = await this.createBrowserTabInRenderer( - url, - worktreeId, - params.profileId - ) - - // Why: the renderer creates the Zustand tab immediately, but the webview must - // mount and fire dom-ready before registerGuest runs. Waiting here ensures the - // tab is operable by subsequent CLI commands (snapshot, click, etc.). - // If registration doesn't complete within timeout, return the ID anyway — the - // tab exists in the UI but may not be ready for automation commands yet. - try { - await waitForTabRegistration(browserPageId) - } catch { - // Tab was created in the renderer but the webview hasn't finished mounting. - // Return success since the tab exists; subsequent commands will fail with a - // clear "tab not available" error if the webview never loads. - } - - // Why: newly created tabs should be auto-activated so subsequent commands - // (snapshot, click, goto) target the new tab without requiring an explicit - // tab switch. Without this, the bridge's active tab still points at the - // previously active tab and the new tab shows active: false in tab list. - const bridge = this.requireAgentBrowserBridge() - const wcId = bridge.getRegisteredTabs(worktreeId).get(browserPageId) - if (wcId != null) { - bridge.setActiveTab(wcId, worktreeId) - } - - // Why: the renderer sets webview.src=url on mount, but agent-browser connects - // via CDP after the webview loads about:blank. Without an explicit goto, the - // page stays blank from agent-browser's perspective. Navigate via the bridge - // so agent-browser's CDP session tracks the correct page state. - if (url && url !== 'about:blank') { - try { - const result = await bridge.goto(url, worktreeId, browserPageId) - this.notifyRendererNavigation(browserPageId, result.url, result.title) - } catch { - // Tab exists but navigation failed — caller can retry with explicit goto - } - } - - return { browserPageId } - } - - async browserTabSetProfile( - params: { - profileId: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const browserPageId = - target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) - if (!browserPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - // Why: 'default' is a synthetic id; fall back to the registry's default profile when not registered. - const profile = - browserSessionRegistry.getProfile(params.profileId) ?? - (params.profileId === 'default' ? browserSessionRegistry.getDefaultProfile() : null) - if (!profile) { - throw new BrowserError( - 'invalid_argument', - `Browser profile ${params.profileId} was not found` - ) - } - - // Why: short-circuit no-op switches so the renderer doesn't tear down and - // remount the webview when the tab is already on the requested profile. - const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default' - if (currentProfileId === profile.id) { - return { - browserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - const win = this.getAuthoritativeWindow() - const requestId = randomUUID() - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabSetProfileReply', handler) - reject(new Error('Tab profile update timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; error?: string } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabSetProfileReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve() - } - } - ipcMain.on('browser:tabSetProfileReply', handler) - win.webContents.send('browser:requestTabSetProfile', { - requestId, - browserPageId, - profileId: profile.id - }) - }) - - // Why: the renderer destroys the old webview and remounts on the new - // partition. Wait for the re-register so a follow-up tab list - // --show-profile reads the updated sessionProfileId from BrowserManager - // instead of stale data, and so subsequent CLI ops (snapshot, click, etc.) - // hit a guest that's already attached. - try { - await waitForTabRegistration(browserPageId) - } catch { - // Best-effort: re-register won't fire if the worktree is hidden. The - // store already reflects the new profile; downstream commands retry - // once the pane re-mounts. - } - - return { - browserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - async browserTabProfileShow(params: { - page: string - worktree?: string - }): Promise { - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - const tab = this.describeBrowserTab(params.page, worktreeId) - return { - browserPageId: tab.browserPageId, - worktreeId: tab.worktreeId ?? null, - profileId: tab.profileId ?? null, - profileLabel: tab.profileLabel ?? null - } - } - - async browserTabProfileClone( - params: { - profileId: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const sourceBrowserPageId = - target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) - if (!sourceBrowserPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId) - const profile = browserSessionRegistry.getProfile(params.profileId) - if (!profile) { - throw new BrowserError( - 'invalid_argument', - `Browser profile ${params.profileId} was not found` - ) - } - const created = await this.createBrowserTabInRenderer( - sourceTab.url, - sourceTab.worktreeId ?? target.worktreeId, - profile.id - ) - // Why: parity with browserTabCreate. Wait for the cloned tab's webview to - // register so the returned browserPageId is operable by the next CLI call. - try { - await waitForTabRegistration(created.browserPageId) - } catch { - // Best-effort: registration may not fire if the worktree is hidden. - } - return { - browserPageId: created.browserPageId, - sourceBrowserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - async browserProfileList(): Promise { - return { profiles: browserSessionRegistry.listProfiles() } - } - - async browserProfileCreate(params: { - label: string - scope: 'isolated' | 'imported' - }): Promise { - return { - profile: browserSessionRegistry.createProfile(params.scope, params.label) - } - } - - async browserProfileDelete(params: { profileId: string }): Promise { - return { - deleted: await browserSessionRegistry.deleteProfile(params.profileId), - profileId: params.profileId - } - } - - async browserTabClose(params: { - index?: number - page?: string - worktree?: string - }): Promise<{ closed: boolean }> { - const bridge = this.requireAgentBrowserBridge() - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - - let tabId: string | null = null - if (typeof params.page === 'string' && params.page.length > 0) { - if (!bridge.getRegisteredTabs(worktreeId).has(params.page)) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${params.page} was not found${scope}` - ) - } - tabId = params.page - } else if (params.index !== undefined) { - const tabs = bridge.getRegisteredTabs(worktreeId) - const entries = [...tabs.entries()] - if (params.index < 0 || params.index >= entries.length) { - throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`) - } - tabId = entries[params.index][0] - } else { - // Why: try the bridge first (registered tabs with webviews), then fall back - // to asking the renderer to close its active browser tab (handles cases where - // the webview hasn't mounted yet, e.g. tab was just created). - const tabs = bridge.getRegisteredTabs(worktreeId) - const entries = [...tabs.entries()] - const activeEntry = entries.find(([, wcId]) => wcId === bridge.getActiveWebContentsId()) - if (activeEntry) { - tabId = activeEntry[0] - } - } - - const win = this.getAuthoritativeWindow() - const requestId = randomUUID() - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCloseReply', handler) - reject(new Error('Tab close timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; error?: string } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCloseReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve() - } - } - ipcMain.on('browser:tabCloseReply', handler) - // Why: when main cannot resolve a concrete tab id itself (for example if a - // browser workspace exists in the renderer before its guest mounts), the - // renderer still needs the intended worktree scope. Otherwise it falls - // back to the globally active browser tab and can close a tab in the - // wrong worktree. - win.webContents.send('browser:requestTabClose', { requestId, tabId, worktreeId }) - }) - - return { closed: true } - } - - private enrichBrowserTabInfo( - tab: BrowserTabListResult['tabs'][number] - ): BrowserTabListResult['tabs'][number] { - const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) - const profile = - browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? - browserSessionRegistry.getDefaultProfile() - return { - ...tab, - worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, - profileId: profile.id, - profileLabel: profile.label - } - } - - private describeBrowserTab( - browserPageId: string, - explicitWorktreeId?: string - ): BrowserTabListResult['tabs'][number] { - const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId) - const tab = this.requireAgentBrowserBridge() - .tabList(worktreeId) - .tabs.find((entry) => entry.browserPageId === browserPageId) - if (!tab) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} was not found${scope}` - ) - } - return this.enrichBrowserTabInfo(tab) - } - - private async createBrowserTabInRenderer( - url: string, - worktreeId?: string, - profileId?: string - ): Promise<{ browserPageId: string }> { - const win = this.getAuthoritativeWindow() - const requestId = randomUUID() - - if (worktreeId) { - await this.ensureBrowserWorktreeActive(worktreeId) - } - - const browserPageId = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCreateReply', handler) - reject(new Error('Tab creation timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; browserPageId?: string; error?: string } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCreateReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve(reply.browserPageId!) - } - } - ipcMain.on('browser:tabCreateReply', handler) - win.webContents.send('browser:requestTabCreate', { - requestId, - url, - worktreeId, - sessionProfileId: profileId - }) - }) - - return { browserPageId } - } + private readonly browserCommands = new RuntimeBrowserCommands({ + getAgentBrowserBridge: () => this.agentBrowserBridge, + resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), + getAuthoritativeWindow: () => this.getAuthoritativeWindow(), + getAvailableAuthoritativeWindow: () => this.getAvailableAuthoritativeWindow() + }) + + browserSnapshot: RuntimeBrowserCommands['browserSnapshot'] = + this.browserCommands.browserSnapshot.bind(this.browserCommands) + + browserClick: RuntimeBrowserCommands['browserClick'] = this.browserCommands.browserClick.bind( + this.browserCommands + ) + + browserGoto: RuntimeBrowserCommands['browserGoto'] = this.browserCommands.browserGoto.bind( + this.browserCommands + ) + + browserFill: RuntimeBrowserCommands['browserFill'] = this.browserCommands.browserFill.bind( + this.browserCommands + ) + + browserType: RuntimeBrowserCommands['browserType'] = this.browserCommands.browserType.bind( + this.browserCommands + ) + + browserSelect: RuntimeBrowserCommands['browserSelect'] = this.browserCommands.browserSelect.bind( + this.browserCommands + ) + + browserScroll: RuntimeBrowserCommands['browserScroll'] = this.browserCommands.browserScroll.bind( + this.browserCommands + ) + + browserBack: RuntimeBrowserCommands['browserBack'] = this.browserCommands.browserBack.bind( + this.browserCommands + ) + + browserReload: RuntimeBrowserCommands['browserReload'] = this.browserCommands.browserReload.bind( + this.browserCommands + ) + + browserScreenshot: RuntimeBrowserCommands['browserScreenshot'] = + this.browserCommands.browserScreenshot.bind(this.browserCommands) + + browserEval: RuntimeBrowserCommands['browserEval'] = this.browserCommands.browserEval.bind( + this.browserCommands + ) + + browserTabList: RuntimeBrowserCommands['browserTabList'] = + this.browserCommands.browserTabList.bind(this.browserCommands) + + browserTabShow: RuntimeBrowserCommands['browserTabShow'] = + this.browserCommands.browserTabShow.bind(this.browserCommands) + + browserTabCurrent: RuntimeBrowserCommands['browserTabCurrent'] = + this.browserCommands.browserTabCurrent.bind(this.browserCommands) + + browserTabSwitch: RuntimeBrowserCommands['browserTabSwitch'] = + this.browserCommands.browserTabSwitch.bind(this.browserCommands) + + browserHover: RuntimeBrowserCommands['browserHover'] = this.browserCommands.browserHover.bind( + this.browserCommands + ) + + browserDrag: RuntimeBrowserCommands['browserDrag'] = this.browserCommands.browserDrag.bind( + this.browserCommands + ) + + browserUpload: RuntimeBrowserCommands['browserUpload'] = this.browserCommands.browserUpload.bind( + this.browserCommands + ) + + browserWait: RuntimeBrowserCommands['browserWait'] = this.browserCommands.browserWait.bind( + this.browserCommands + ) + + browserCheck: RuntimeBrowserCommands['browserCheck'] = this.browserCommands.browserCheck.bind( + this.browserCommands + ) + + browserFocus: RuntimeBrowserCommands['browserFocus'] = this.browserCommands.browserFocus.bind( + this.browserCommands + ) + + browserClear: RuntimeBrowserCommands['browserClear'] = this.browserCommands.browserClear.bind( + this.browserCommands + ) + + browserSelectAll: RuntimeBrowserCommands['browserSelectAll'] = + this.browserCommands.browserSelectAll.bind(this.browserCommands) + + browserKeypress: RuntimeBrowserCommands['browserKeypress'] = + this.browserCommands.browserKeypress.bind(this.browserCommands) + + browserPdf: RuntimeBrowserCommands['browserPdf'] = this.browserCommands.browserPdf.bind( + this.browserCommands + ) + + browserFullScreenshot: RuntimeBrowserCommands['browserFullScreenshot'] = + this.browserCommands.browserFullScreenshot.bind(this.browserCommands) + + browserCookieGet: RuntimeBrowserCommands['browserCookieGet'] = + this.browserCommands.browserCookieGet.bind(this.browserCommands) + + browserCookieSet: RuntimeBrowserCommands['browserCookieSet'] = + this.browserCommands.browserCookieSet.bind(this.browserCommands) + + browserCookieDelete: RuntimeBrowserCommands['browserCookieDelete'] = + this.browserCommands.browserCookieDelete.bind(this.browserCommands) + + browserSetViewport: RuntimeBrowserCommands['browserSetViewport'] = + this.browserCommands.browserSetViewport.bind(this.browserCommands) + + browserSetGeolocation: RuntimeBrowserCommands['browserSetGeolocation'] = + this.browserCommands.browserSetGeolocation.bind(this.browserCommands) + + browserInterceptEnable: RuntimeBrowserCommands['browserInterceptEnable'] = + this.browserCommands.browserInterceptEnable.bind(this.browserCommands) + + browserInterceptDisable: RuntimeBrowserCommands['browserInterceptDisable'] = + this.browserCommands.browserInterceptDisable.bind(this.browserCommands) + + browserInterceptList: RuntimeBrowserCommands['browserInterceptList'] = + this.browserCommands.browserInterceptList.bind(this.browserCommands) + + browserCaptureStart: RuntimeBrowserCommands['browserCaptureStart'] = + this.browserCommands.browserCaptureStart.bind(this.browserCommands) + + browserCaptureStop: RuntimeBrowserCommands['browserCaptureStop'] = + this.browserCommands.browserCaptureStop.bind(this.browserCommands) + + browserConsoleLog: RuntimeBrowserCommands['browserConsoleLog'] = + this.browserCommands.browserConsoleLog.bind(this.browserCommands) + + browserNetworkLog: RuntimeBrowserCommands['browserNetworkLog'] = + this.browserCommands.browserNetworkLog.bind(this.browserCommands) + + browserDblclick: RuntimeBrowserCommands['browserDblclick'] = + this.browserCommands.browserDblclick.bind(this.browserCommands) + + browserForward: RuntimeBrowserCommands['browserForward'] = + this.browserCommands.browserForward.bind(this.browserCommands) + + browserScrollIntoView: RuntimeBrowserCommands['browserScrollIntoView'] = + this.browserCommands.browserScrollIntoView.bind(this.browserCommands) + + browserGet: RuntimeBrowserCommands['browserGet'] = this.browserCommands.browserGet.bind( + this.browserCommands + ) + + browserIs: RuntimeBrowserCommands['browserIs'] = this.browserCommands.browserIs.bind( + this.browserCommands + ) + + browserKeyboardInsertText: RuntimeBrowserCommands['browserKeyboardInsertText'] = + this.browserCommands.browserKeyboardInsertText.bind(this.browserCommands) + + browserMouseMove: RuntimeBrowserCommands['browserMouseMove'] = + this.browserCommands.browserMouseMove.bind(this.browserCommands) + + browserMouseDown: RuntimeBrowserCommands['browserMouseDown'] = + this.browserCommands.browserMouseDown.bind(this.browserCommands) + + browserMouseUp: RuntimeBrowserCommands['browserMouseUp'] = + this.browserCommands.browserMouseUp.bind(this.browserCommands) + + browserMouseWheel: RuntimeBrowserCommands['browserMouseWheel'] = + this.browserCommands.browserMouseWheel.bind(this.browserCommands) + + browserFind: RuntimeBrowserCommands['browserFind'] = this.browserCommands.browserFind.bind( + this.browserCommands + ) + + browserSetDevice: RuntimeBrowserCommands['browserSetDevice'] = + this.browserCommands.browserSetDevice.bind(this.browserCommands) + + browserSetOffline: RuntimeBrowserCommands['browserSetOffline'] = + this.browserCommands.browserSetOffline.bind(this.browserCommands) + + browserSetHeaders: RuntimeBrowserCommands['browserSetHeaders'] = + this.browserCommands.browserSetHeaders.bind(this.browserCommands) + + browserSetCredentials: RuntimeBrowserCommands['browserSetCredentials'] = + this.browserCommands.browserSetCredentials.bind(this.browserCommands) + + browserSetMedia: RuntimeBrowserCommands['browserSetMedia'] = + this.browserCommands.browserSetMedia.bind(this.browserCommands) + + browserClipboardRead: RuntimeBrowserCommands['browserClipboardRead'] = + this.browserCommands.browserClipboardRead.bind(this.browserCommands) + + browserClipboardWrite: RuntimeBrowserCommands['browserClipboardWrite'] = + this.browserCommands.browserClipboardWrite.bind(this.browserCommands) + + browserDialogAccept: RuntimeBrowserCommands['browserDialogAccept'] = + this.browserCommands.browserDialogAccept.bind(this.browserCommands) + + browserDialogDismiss: RuntimeBrowserCommands['browserDialogDismiss'] = + this.browserCommands.browserDialogDismiss.bind(this.browserCommands) + + browserStorageLocalGet: RuntimeBrowserCommands['browserStorageLocalGet'] = + this.browserCommands.browserStorageLocalGet.bind(this.browserCommands) + + browserStorageLocalSet: RuntimeBrowserCommands['browserStorageLocalSet'] = + this.browserCommands.browserStorageLocalSet.bind(this.browserCommands) + + browserStorageLocalClear: RuntimeBrowserCommands['browserStorageLocalClear'] = + this.browserCommands.browserStorageLocalClear.bind(this.browserCommands) + + browserStorageSessionGet: RuntimeBrowserCommands['browserStorageSessionGet'] = + this.browserCommands.browserStorageSessionGet.bind(this.browserCommands) + + browserStorageSessionSet: RuntimeBrowserCommands['browserStorageSessionSet'] = + this.browserCommands.browserStorageSessionSet.bind(this.browserCommands) + + browserStorageSessionClear: RuntimeBrowserCommands['browserStorageSessionClear'] = + this.browserCommands.browserStorageSessionClear.bind(this.browserCommands) + + browserDownload: RuntimeBrowserCommands['browserDownload'] = + this.browserCommands.browserDownload.bind(this.browserCommands) + + browserHighlight: RuntimeBrowserCommands['browserHighlight'] = + this.browserCommands.browserHighlight.bind(this.browserCommands) + + browserExec: RuntimeBrowserCommands['browserExec'] = this.browserCommands.browserExec.bind( + this.browserCommands + ) + + browserTabCreate: RuntimeBrowserCommands['browserTabCreate'] = + this.browserCommands.browserTabCreate.bind(this.browserCommands) + + browserTabSetProfile: RuntimeBrowserCommands['browserTabSetProfile'] = + this.browserCommands.browserTabSetProfile.bind(this.browserCommands) + + browserTabProfileShow: RuntimeBrowserCommands['browserTabProfileShow'] = + this.browserCommands.browserTabProfileShow.bind(this.browserCommands) + + browserTabProfileClone: RuntimeBrowserCommands['browserTabProfileClone'] = + this.browserCommands.browserTabProfileClone.bind(this.browserCommands) + + browserProfileList: RuntimeBrowserCommands['browserProfileList'] = + this.browserCommands.browserProfileList.bind(this.browserCommands) + + browserProfileCreate: RuntimeBrowserCommands['browserProfileCreate'] = + this.browserCommands.browserProfileCreate.bind(this.browserCommands) + + browserProfileDelete: RuntimeBrowserCommands['browserProfileDelete'] = + this.browserCommands.browserProfileDelete.bind(this.browserCommands) + + browserProfileDetectBrowsers: RuntimeBrowserCommands['browserProfileDetectBrowsers'] = + this.browserCommands.browserProfileDetectBrowsers.bind(this.browserCommands) + + browserProfileImportFromBrowser: RuntimeBrowserCommands['browserProfileImportFromBrowser'] = + this.browserCommands.browserProfileImportFromBrowser.bind(this.browserCommands) + + browserProfileClearDefaultCookies: RuntimeBrowserCommands['browserProfileClearDefaultCookies'] = + this.browserCommands.browserProfileClearDefaultCookies.bind(this.browserCommands) + + browserTabClose: RuntimeBrowserCommands['browserTabClose'] = + this.browserCommands.browserTabClose.bind(this.browserCommands) private getAuthoritativeWindow(): BrowserWindow { - if (this.authoritativeWindowId === null) { - throw new Error('No renderer window available') - } - const win = BrowserWindow.fromId(this.authoritativeWindowId) + const win = this.getAvailableAuthoritativeWindow() if (!win || win.isDestroyed()) { throw new Error('No renderer window available') } return win } + + private getAvailableAuthoritativeWindow(): BrowserWindow | null { + if (this.authoritativeWindowId === null) { + return null + } + if (!BrowserWindow?.fromId) { + return null + } + const win = BrowserWindow.fromId(this.authoritativeWindowId) + return win && !win.isDestroyed() ? win : null + } } const MAX_TAIL_LINES = 120 @@ -7922,6 +8276,10 @@ function branchSelectorMatches(branch: string, selector: string): boolean { return normalizeBranchRef(branch) === normalizeBranchRef(selector) } +function runtimePathsEqual(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} + function normalizeBranchRef(branch: string): string { return branch.startsWith('refs/heads/') ? branch.slice('refs/heads/'.length) : branch } @@ -7956,23 +8314,11 @@ function findResolvedWorktreeIdForPath( return null } const matches = resolvedWorktrees - .filter( - (worktree) => - areWorktreePathsEqual(worktree.path, cwd) || isPathInsideWorktree(cwd, worktree.path) - ) + .filter((worktree) => isPathInsideOrEqual(worktree.path, cwd)) .sort((left, right) => right.path.length - left.path.length) return matches[0]?.id ?? null } -function isPathInsideWorktree(candidatePath: string, worktreePath: string): boolean { - if (candidatePath === worktreePath) { - return true - } - const normalizedCandidate = candidatePath.replace(/\\/g, '/').replace(/\/+$/, '') - const normalizedWorktree = worktreePath.replace(/\\/g, '/').replace(/\/+$/, '') - return normalizedCandidate.startsWith(`${normalizedWorktree}/`) -} - function getLeafWorktreeStatus( leaf: RuntimeLeafRecord, tabTitle: string | null @@ -8033,72 +8379,6 @@ function maxTimestamp(left: number | null, right: number | null): number | null return Math.max(left, right) } -function isSafeMobileRelativePath(relativePath: string): boolean { - if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) { - return false - } - const parts = relativePath.replace(/\\/g, '/').split('/') - return parts.every((part) => part !== '' && part !== '.' && part !== '..') -} - -function isMobileMarkdownPath(relativePath: string): boolean { - return /\.(md|mdx|markdown)$/i.test(relativePath) -} - -function isMobileBinaryPath(relativePath: string): boolean { - const basename = basenameFromRelativePath(relativePath) - const dotIndex = basename.lastIndexOf('.') - if (dotIndex <= 0) { - return false - } - return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) -} - -function basenameFromRelativePath(relativePath: string): string { - const normalized = relativePath.replace(/\\/g, '/') - return normalized.slice(normalized.lastIndexOf('/') + 1) -} - -function joinWorktreeRelativePath(rootPath: string, relativePath: string): string { - const normalizedRelativePath = relativePath.replace(/\\/g, '/') - if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { - return win32.join(rootPath, ...normalizedRelativePath.split('/')) - } - return posix.join(rootPath, ...normalizedRelativePath.split('/')) -} - -async function readLocalMobileFile(filePath: string, store: Store): Promise { - const authorizedPath = await resolveAuthorizedPath(filePath, store) - const fileStat = await stat(authorizedPath) - // Why: mobile file previews are read-only convenience views; cap the read so - // opening a generated log or bundle cannot block the WebSocket like oversized scrollback. - const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1) - const handle = await open(authorizedPath, 'r') - try { - const buffer = Buffer.alloc(readLimit) - const { bytesRead } = await handle.read(buffer, 0, readLimit, 0) - return buffer.subarray(0, bytesRead).toString('utf8') - } finally { - await handle.close() - } -} - -function truncateMobileFilePreview(content: string): { - content: string - truncated: boolean - byteLength: number -} { - const buffer = Buffer.from(content, 'utf8') - if (buffer.byteLength <= MOBILE_FILE_READ_MAX_BYTES) { - return { content, truncated: false, byteLength: buffer.byteLength } - } - return { - content: buffer.subarray(0, MOBILE_FILE_READ_MAX_BYTES).toString('utf8'), - truncated: true, - byteLength: buffer.byteLength - } -} - function compareWorktreePs( left: RuntimeWorktreePsSummary, right: RuntimeWorktreePsSummary diff --git a/src/cli/orchestration.subprocess.test.ts b/src/main/runtime/orchestration-cli-subprocess.test.ts similarity index 93% rename from src/cli/orchestration.subprocess.test.ts rename to src/main/runtime/orchestration-cli-subprocess.test.ts index c8648ceafe6..054328a9653 100644 --- a/src/cli/orchestration.subprocess.test.ts +++ b/src/main/runtime/orchestration-cli-subprocess.test.ts @@ -20,9 +20,9 @@ import { existsSync, mkdtempSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { describe, expect, it } from 'vitest' -import { OrcaRuntimeService } from '../main/runtime/orca-runtime' -import { OrchestrationDb } from '../main/runtime/orchestration/db' -import { OrcaRuntimeRpcServer } from '../main/runtime/runtime-rpc' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' +import { OrcaRuntimeRpcServer } from './runtime-rpc' // Why: Vitest runs tests with `process.cwd()` pinned to the repo root, so // join against it to locate the compiled CLI regardless of where this test @@ -108,12 +108,12 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { expect(heartbeatLines[0]).toHaveProperty('elapsedMs') expect(heartbeatLines[0]).toHaveProperty('deadlineMs', waitTimeoutMs) - // Why: first heartbeat must arrive within one interval + scheduler - // slack (300ms is generous); if the stream were fully buffered we'd - // see everything only after exit. + // Why: under full-suite load the child process startup may take longer + // than one heartbeat interval. The invariant that matters is that at + // least one heartbeat is observed before the terminal stdout payload. const firstHeartbeatChunk = stderrChunks.find((c) => c.data.includes('_heartbeat')) expect(firstHeartbeatChunk).toBeDefined() - expect(firstHeartbeatChunk!.at).toBeLessThan(heartbeatMs + 300) + expect(firstHeartbeatChunk!.at).toBeLessThan(stdoutChunks[0]?.at ?? Number.POSITIVE_INFINITY) // Why: line-flushing proof — the *first* heartbeat chunk must arrive // strictly before the exit chunk; i.e. we got at least two separate diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts new file mode 100644 index 00000000000..540ad425b58 --- /dev/null +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { getDefaultRepoHookSettings } from '../../shared/constants' +import type { Repo } from '../../shared/types' +import { parsePairingCode } from '../../shared/pairing' +import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' +import type { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' + +describe('remote runtime request connection integration', () => { + it('fetches repos through the real E2EE WebSocket runtime', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-request-')) + const repoPath = join(userDataPath, 'repo') + const repos: Repo[] = [ + { + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + hookSettings: getDefaultRepoHookSettings(), + worktreeBaseRef: 'main', + kind: 'git' + } + ] + const runtime = { + getRuntimeId: () => 'runtime-test', + getStartedAt: () => 1, + cleanupSubscriptionsForConnection: () => {}, + cancelMobileDictationForConnection: () => {}, + onClientDisconnected: () => {}, + listRepos: () => repos + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + const pairing = parsePairingCode(offer.pairingUrl) + if (!pairing) { + throw new Error('invalid pairing') + } + const connection = new RemoteRuntimeRequestConnection(pairing) + try { + await expect(connection.request('repo.list', undefined, 1000)).resolves.toMatchObject({ + ok: true, + result: { repos } + }) + } finally { + connection.close() + } + } finally { + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index bdbc6e5b63b..4e51248c0b4 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -3,6 +3,7 @@ // CLI-facing contract greppable and lets the dispatcher verify every payload // against the same shape the handler consumed during development. import { ZodError, type ZodType } from 'zod' +import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { OrcaRuntimeService } from '../orca-runtime' export type RpcEnvelopeMeta = { @@ -59,6 +60,13 @@ export type RpcContext = { // responses after the binary terminal cutover. Undefined on Unix/socket // transports and non-E2EE WebSocket paths. sendBinary?: (bytes: Uint8Array) => void + // Why: binary terminal input/resize frames arrive outside JSON-RPC after a + // stream is established. The WebSocket transport owns the connection-scoped + // stream table; handlers register only the stream IDs they created. + registerBinaryStreamHandler?: ( + streamId: number, + handler: (frame: TerminalStreamFrame) => void + ) => () => void } export type RpcHandler = (params: TParams, ctx: RpcContext) => Promise | unknown diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index edd247cd92e..aa693b5b1b1 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -13,6 +13,7 @@ import { type RpcRequest, type RpcResponse } from './core' +import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors' import { ALL_RPC_METHODS } from './methods' import type { OrcaRuntimeService } from '../orca-runtime' @@ -79,8 +80,13 @@ export class RpcDispatcher { reply: (response: string) => void, options?: { connectionId?: string + signal?: AbortSignal clientId?: string sendBinary?: (bytes: Uint8Array) => void + registerBinaryStreamHandler?: ( + streamId: number, + handler: (frame: TerminalStreamFrame) => void + ) => () => void } ): Promise { const meta = this.meta() @@ -104,9 +110,11 @@ export class RpcDispatcher { try { const result = await method.handler(parsedParams.value, { runtime: this.runtime, + signal: options?.signal, connectionId: options?.connectionId, clientId: options?.clientId, - sendBinary: options?.sendBinary + sendBinary: options?.sendBinary, + registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) reply(JSON.stringify(successResponse(request.id, meta, result))) } catch (error) { @@ -126,9 +134,11 @@ export class RpcDispatcher { parsedParams.value, { runtime: this.runtime, + signal: options?.signal, connectionId: options?.connectionId, clientId: options?.clientId, - sendBinary: options?.sendBinary + sendBinary: options?.sendBinary, + registerBinaryStreamHandler: options?.registerBinaryStreamHandler }, emit ) diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts index 69b242c4ca8..a1daec13669 100644 --- a/src/main/runtime/rpc/e2ee-channel.test.ts +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type { WebSocket } from 'ws' import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' -import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt, encryptBytes } from './e2ee-crypto' function publicKeyToBase64(key: Uint8Array): string { return Buffer.from(key).toString('base64') @@ -182,6 +182,20 @@ describe('E2EEChannel', () => { expect(replyPlain).toBe('{"id":"rpc-1","ok":true}') }) + it('decrypts and forwards binary messages after authentication', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const received: Uint8Array[] = [] + + ctx.channel.onBinaryMessage((bytes) => { + received.push(bytes) + }) + + ctx.channel.handleRawMessage(encryptBytes(new Uint8Array([1, 2, 3]), sharedKey)) + + expect([...received[0]!]).toEqual([1, 2, 3]) + }) + it('silently drops messages with wrong key', () => { const ctx = setup() doHandshake(ctx) diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index a105b882a0a..279da548310 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -46,6 +46,7 @@ export class E2EEChannel { encryptedBinaryReply: (response: Uint8Array) => void ) => void) | null = null + private binaryMessageHandler: ((plaintext: Uint8Array) => void) | null = null deviceToken: string | null = null @@ -71,6 +72,10 @@ export class E2EEChannel { this.messageHandler = handler } + onBinaryMessage(handler: (plaintext: Uint8Array) => void): void { + this.binaryMessageHandler = handler + } + handleRawMessage(raw: string | Uint8Array): void { if (this.state === 'awaiting_hello') { if (typeof raw !== 'string') { @@ -89,7 +94,14 @@ export class E2EEChannel { const plaintextBytes = decryptBytes(raw, this.sharedKey) if (plaintextBytes === null) { this.trackDecryptFailure() + return } + this.consecutiveFailures = 0 + if (this.state !== 'ready') { + this.onError(4001, 'Invalid binary message before authentication') + return + } + this.binaryMessageHandler?.(plaintextBytes) return } @@ -211,5 +223,6 @@ export class E2EEChannel { } this.sharedKey = null this.messageHandler = null + this.binaryMessageHandler = null } } diff --git a/src/main/runtime/rpc/e2ee-crypto.ts b/src/main/runtime/rpc/e2ee-crypto.ts index 40818c424b5..f0f4f460d83 100644 --- a/src/main/runtime/rpc/e2ee-crypto.ts +++ b/src/main/runtime/rpc/e2ee-crypto.ts @@ -1,53 +1,8 @@ -// Why: shared E2EE primitives for the desktop side. Wraps tweetnacl to provide -// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext]. JSON -// RPC uses base64 text frames; terminal streams use the raw byte bundle. -import nacl from 'tweetnacl' - -export function generateKeyPair(): nacl.BoxKeyPair { - return nacl.box.keyPair() -} - -export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { - return nacl.box.before(peerPublicKey, ourSecretKey) -} - -export function encrypt(plaintext: string, sharedKey: Uint8Array): string { - const messageBytes = new TextEncoder().encode(plaintext) - return Buffer.from(encryptBytes(messageBytes, sharedKey)).toString('base64') -} - -export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { - const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) - const plaintext = decryptBytes(bundle, sharedKey) - return plaintext ? new TextDecoder().decode(plaintext) : null -} - -export function encryptBytes( - plaintext: Uint8Array, - sharedKey: Uint8Array -): Uint8Array { - const nonce = nacl.randomBytes(nacl.box.nonceLength) - const ciphertext = nacl.box.after(plaintext, nonce, sharedKey) - - const bundle = new Uint8Array(nonce.length + ciphertext.length) - bundle.set(nonce) - bundle.set(ciphertext, nonce.length) - - return bundle -} - -export function decryptBytes(bundle: Uint8Array, sharedKey: Uint8Array): Uint8Array | null { - if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { - return null - } - - const nonce = bundle.slice(0, nacl.box.nonceLength) - const ciphertext = bundle.slice(nacl.box.nonceLength) - const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) - - if (!plaintext) { - return null - } - - return plaintext -} +export { + decrypt, + decryptBytes, + deriveSharedKey, + encrypt, + encryptBytes, + generateKeyPair +} from '../../../shared/e2ee-crypto' diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index b141c0b14a3..3736aaf5e74 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -18,6 +18,7 @@ import { LimitParam, ProfileCreate, ProfileDelete, + ProfileImportFromBrowser, Screenshot, Scroll, Select, @@ -151,6 +152,21 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: ProfileDelete, handler: async (params, { runtime }) => runtime.browserProfileDelete(params) }), + defineMethod({ + name: 'browser.profileDetectBrowsers', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileDetectBrowsers() + }), + defineMethod({ + name: 'browser.profileImportFromBrowser', + params: ProfileImportFromBrowser, + handler: async (params, { runtime }) => runtime.browserProfileImportFromBrowser(params) + }), + defineMethod({ + name: 'browser.profileClearDefaultCookies', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileClearDefaultCookies() + }), defineMethod({ name: 'browser.hover', params: Element, diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 0a216c1d9e0..1009429d625 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -143,6 +143,12 @@ export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') }) +export const ProfileImportFromBrowser = z.object({ + profileId: requiredString('Missing required --profile'), + browserFamily: requiredString('Missing required --browser-family'), + browserProfile: OptionalString +}) + export const Drag = BrowserTarget.extend({ from: requiredString('Missing required --from and --to element refs'), to: requiredString('Missing required --from and --to element refs') diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts new file mode 100644 index 00000000000..0312593e6b8 --- /dev/null +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { BROWSER_CORE_METHODS } from './browser-core' +import { BROWSER_EXTRA_METHODS } from './browser-extras' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('browser RPC methods', () => { + it('routes core browser automation commands to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + browserSnapshot: vi.fn().mockResolvedValue({ elements: [] }), + browserGoto: vi.fn().mockResolvedValue({ url: 'https://example.com' }), + browserProfileDetectBrowsers: vi.fn().mockResolvedValue({ browsers: [] }), + browserProfileImportFromBrowser: vi.fn().mockResolvedValue({ ok: false, reason: 'empty' }), + browserTabCreate: vi.fn().mockResolvedValue({ browserPageId: 'page-1' }), + browserTabSwitch: vi.fn().mockResolvedValue({ browserPageId: 'page-1' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS }) + + await dispatcher.dispatch(makeRequest('browser.snapshot', { worktree: 'id:wt-1' })) + await dispatcher.dispatch( + makeRequest('browser.goto', { + worktree: 'id:wt-1', + page: 'page-1', + url: 'https://example.com' + }) + ) + await dispatcher.dispatch( + makeRequest('browser.tabCreate', { + worktree: 'id:wt-1', + url: 'https://example.com', + profileId: 'profile-1' + }) + ) + await dispatcher.dispatch( + makeRequest('browser.tabSwitch', { + worktree: 'id:wt-1', + index: 0, + focus: true + }) + ) + await dispatcher.dispatch(makeRequest('browser.profileDetectBrowsers')) + await dispatcher.dispatch( + makeRequest('browser.profileImportFromBrowser', { + profileId: 'profile-1', + browserFamily: 'chrome', + browserProfile: 'Default' + }) + ) + + expect(runtime.browserSnapshot).toHaveBeenCalledWith({ worktree: 'id:wt-1' }) + expect(runtime.browserGoto).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + page: 'page-1', + url: 'https://example.com' + }) + expect(runtime.browserTabCreate).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + url: 'https://example.com', + profileId: 'profile-1' + }) + expect(runtime.browserTabSwitch).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + index: 0, + focus: true + }) + expect(runtime.browserProfileDetectBrowsers).toHaveBeenCalled() + expect(runtime.browserProfileImportFromBrowser).toHaveBeenCalledWith({ + profileId: 'profile-1', + browserFamily: 'chrome', + browserProfile: 'Default' + }) + }) + + it('routes browser session and environment controls to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + browserCookieGet: vi.fn().mockResolvedValue({ cookies: [] }), + browserSetViewport: vi.fn().mockResolvedValue({ ok: true }), + browserMouseWheel: vi.fn().mockResolvedValue({ ok: true }), + browserStorageLocalSet: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_EXTRA_METHODS }) + + await dispatcher.dispatch( + makeRequest('browser.cookie.get', { + worktree: 'id:wt-1', + page: 'page-1', + url: 'https://example.com' + }) + ) + await dispatcher.dispatch( + makeRequest('browser.viewport', { + worktree: 'id:wt-1', + page: 'page-1', + width: 1024, + height: 768 + }) + ) + await dispatcher.dispatch( + makeRequest('browser.mouseWheel', { + worktree: 'id:wt-1', + page: 'page-1', + dy: 240 + }) + ) + await dispatcher.dispatch( + makeRequest('browser.storage.local.set', { + worktree: 'id:wt-1', + page: 'page-1', + key: 'orca', + value: 'enabled' + }) + ) + + expect(runtime.browserCookieGet).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + page: 'page-1', + url: 'https://example.com' + }) + expect(runtime.browserSetViewport).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + page: 'page-1', + width: 1024, + height: 768 + }) + expect(runtime.browserMouseWheel).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + page: 'page-1', + dy: 240 + }) + expect(runtime.browserStorageLocalSet).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + page: 'page-1', + key: 'orca', + value: 'enabled' + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts new file mode 100644 index 00000000000..146ed251b0b --- /dev/null +++ b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts @@ -0,0 +1,65 @@ +import type { FsChangeEvent } from '../../../../shared/types' + +const FILE_WATCH_FLUSH_MS = 150 +const FILE_WATCH_MAX_WAIT_MS = 500 + +export function createFileWatchEventBatcher( + worktree: string, + emit: (result: unknown) => void +): { + push: (events: FsChangeEvent[]) => void + flush: () => void + dispose: () => void +} { + let events: FsChangeEvent[] = [] + let timer: ReturnType | null = null + let firstEventAt = 0 + + const clearTimer = (): void => { + if (!timer) { + return + } + clearTimeout(timer) + timer = null + } + + const flush = (): void => { + clearTimer() + const nextEvents = events.splice(0) + firstEventAt = 0 + if (nextEvents.length === 0) { + return + } + emit({ type: 'changed', worktree, events: nextEvents }) + } + + return { + push(nextEvents: FsChangeEvent[]): void { + if (nextEvents.length === 0) { + return + } + events.push(...nextEvents) + const now = Date.now() + if (firstEventAt === 0) { + firstEventAt = now + } + if (now - firstEventAt >= FILE_WATCH_MAX_WAIT_MS) { + flush() + return + } + clearTimer() + // Why: remote file-watch events cross the runtime WebSocket before the + // renderer refreshes the tree. Match local watcher batching here. + timer = setTimeout(flush, FILE_WATCH_FLUSH_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + }, + flush, + dispose(): void { + clearTimer() + events = [] + firstEventAt = 0 + } + } +} diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index c05f252668e..708121e967b 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: file RPC routing coverage stays together so +the dispatcher contract for read, write, mutation, and watch methods is easy to audit. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' @@ -54,6 +56,117 @@ describe('file RPC methods', () => { }) }) + it('streams file watch changes until the subscription is cleaned up', async () => { + vi.useFakeTimers() + try { + type WatchCallback = ( + events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[] + ) => void + const watchFileExplorer = vi.fn(async (_worktree: string, _callback: WatchCallback) => { + return vi.fn() + }) + const cleanups = new Map void>() + const runtime = { + getRuntimeId: () => 'test-runtime', + watchFileExplorer, + registerSubscriptionCleanup: vi.fn().mockImplementation((id, cleanup) => { + cleanups.set(id, cleanup) + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const replies: unknown[] = [] + + const dispatch = dispatcher.dispatchStreaming( + makeRequest('files.watch', { worktree: 'id:wt-1' }), + (response) => replies.push(JSON.parse(response)) + ) + + await vi.waitFor(() => { + expect(replies).toHaveLength(1) + }) + expect(runtime.watchFileExplorer).toHaveBeenCalledWith('id:wt-1', expect.any(Function)) + expect(replies[0]).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId: expect.stringContaining('files-watch-') } + }) + + const emitWatchChange = watchFileExplorer.mock.calls[0]?.[1] + expect(emitWatchChange).toBeDefined() + emitWatchChange?.([{ kind: 'update', absolutePath: '/repo/readme.md', isDirectory: false }]) + emitWatchChange?.([ + { kind: 'update', absolutePath: '/repo/package.json', isDirectory: false } + ]) + expect(replies).toHaveLength(1) + + await vi.runOnlyPendingTimersAsync() + + expect(replies[1]).toMatchObject({ + ok: true, + streaming: true, + result: { + type: 'changed', + worktree: 'id:wt-1', + events: [ + { kind: 'update', absolutePath: '/repo/readme.md', isDirectory: false }, + { kind: 'update', absolutePath: '/repo/package.json', isDirectory: false } + ] + } + }) + + const ready = replies[0] as { result: { subscriptionId: string } } + cleanups.get(ready.result.subscriptionId)?.() + await dispatch + + expect(replies[2]).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'end' } + }) + } finally { + vi.useRealTimers() + } + }) + + it('tears down a file watch that resolves after the connection already closed', async () => { + type WatchCallback = ( + events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[] + ) => void + const unwatch = vi.fn() + let resolveWatch: (value: () => void) => void = () => {} + const watchFileExplorer = vi.fn((_worktree: string, _callback: WatchCallback) => { + return new Promise<() => void>((resolve) => { + resolveWatch = resolve + }) + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + watchFileExplorer, + registerSubscriptionCleanup: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const abortController = new AbortController() + const replies: unknown[] = [] + + const dispatch = dispatcher.dispatchStreaming( + makeRequest('files.watch', { worktree: 'id:wt-1' }), + (response) => replies.push(JSON.parse(response)), + { connectionId: 'conn-1', signal: abortController.signal } + ) + await vi.waitFor(() => { + expect(watchFileExplorer).toHaveBeenCalled() + }) + abortController.abort() + await dispatch + + resolveWatch(unwatch) + await vi.waitFor(() => { + expect(unwatch).toHaveBeenCalled() + }) + expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled() + expect(replies).toEqual([]) + }) + it('reads a relative file path for a selected worktree', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -77,4 +190,285 @@ describe('file RPC methods', () => { result: { content: 'export {}\\n', truncated: false } }) }) + + it('reads a preview file for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + readFileExplorerPreview: vi.fn().mockResolvedValue({ + content: 'base64', + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.readPreview', { worktree: 'id:wt-1', relativePath: 'img/logo.png' }) + ) + + expect(runtime.readFileExplorerPreview).toHaveBeenCalledWith('id:wt-1', 'img/logo.png') + expect(response).toMatchObject({ + ok: true, + result: { content: 'base64', isBinary: true, mimeType: 'image/png' } + }) + }) + + it('reads a file explorer directory for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + readFileExplorerDir: vi.fn().mockResolvedValue([{ name: 'src', isDirectory: true }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.readDir', { worktree: 'id:wt-1', relativePath: '' }) + ) + + expect(runtime.readFileExplorerDir).toHaveBeenCalledWith('id:wt-1', '') + expect(response).toMatchObject({ + ok: true, + result: [{ name: 'src', isDirectory: true }] + }) + }) + + it('writes file explorer content for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.write', { + worktree: 'id:wt-1', + relativePath: 'src/index.ts', + content: 'export {}' + }) + ) + + expect(runtime.writeFileExplorerFile).toHaveBeenCalledWith( + 'id:wt-1', + 'src/index.ts', + 'export {}' + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('writes base64 file explorer content for runtime uploads', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.writeBase64', { + worktree: 'id:wt-1', + relativePath: 'assets/logo.png', + contentBase64: 'cG5n' + }) + ) + + expect(runtime.writeFileExplorerFileBase64).toHaveBeenCalledWith( + 'id:wt-1', + 'assets/logo.png', + 'cG5n' + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('writes base64 file explorer content chunks for large runtime uploads', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64Chunk: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.writeBase64Chunk', { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + contentBase64: 'AAAA', + append: true + }) + ) + + expect(runtime.writeFileExplorerFileBase64Chunk).toHaveBeenCalledWith( + 'id:wt-1', + 'assets/video.mov', + 'AAAA', + true + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('commits staged runtime uploads without clobbering the final destination', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + commitFileExplorerUpload: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.commitUpload', { + worktree: 'id:wt-1', + tempRelativePath: 'assets/.logo.png.orca-upload-a', + finalRelativePath: 'assets/logo.png' + }) + ) + + expect(runtime.commitFileExplorerUpload).toHaveBeenCalledWith( + 'id:wt-1', + 'assets/.logo.png.orca-upload-a', + 'assets/logo.png' + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('renames file explorer paths for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + renameFileExplorerPath: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.rename', { + worktree: 'id:wt-1', + oldRelativePath: 'old.ts', + newRelativePath: 'new.ts' + }) + ) + + expect(runtime.renameFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'old.ts', 'new.ts') + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('copies file explorer paths for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + copyFileExplorerPath: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.copy', { + worktree: 'id:wt-1', + sourceRelativePath: 'old.ts', + destinationRelativePath: 'old copy.ts' + }) + ) + + expect(runtime.copyFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'old.ts', 'old copy.ts') + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('deletes file explorer paths for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + deleteFileExplorerPath: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.delete', { + worktree: 'id:wt-1', + relativePath: 'src', + recursive: true + }) + ) + + expect(runtime.deleteFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'src', true) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('searches files for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + searchRuntimeFiles: vi.fn().mockResolvedValue({ + files: [], + totalMatches: 0, + truncated: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.search', { + worktree: 'id:wt-1', + query: 'needle', + caseSensitive: true, + maxResults: 50 + }) + ) + + expect(runtime.searchRuntimeFiles).toHaveBeenCalledWith('id:wt-1', { + query: 'needle', + caseSensitive: true, + wholeWord: undefined, + useRegex: undefined, + includePattern: undefined, + excludePattern: undefined, + maxResults: 50 + }) + expect(response).toMatchObject({ ok: true, result: { files: [], totalMatches: 0 } }) + }) + + it('lists all quick-open files for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts']) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.listAll', { + worktree: 'id:wt-1', + excludePaths: ['/repo/other-worktree'] + }) + ) + + expect(runtime.listRuntimeFiles).toHaveBeenCalledWith('id:wt-1', { + excludePaths: ['/repo/other-worktree'] + }) + expect(response).toMatchObject({ ok: true, result: ['src/index.ts'] }) + }) + + it('lists markdown documents for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRuntimeMarkdownDocuments: vi.fn().mockResolvedValue([ + { + filePath: '/repo/readme.md', + relativePath: 'readme.md', + basename: 'readme.md', + name: 'readme' + } + ]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.listMarkdownDocuments', { worktree: 'id:wt-1' }) + ) + + expect(runtime.listRuntimeMarkdownDocuments).toHaveBeenCalledWith('id:wt-1') + expect(response).toMatchObject({ ok: true, result: [{ relativePath: 'readme.md' }] }) + }) + + it('stats a relative path for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + statRuntimeFile: vi.fn().mockResolvedValue({ size: 12, isDirectory: false, mtime: 1 }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.stat', { worktree: 'id:wt-1', relativePath: 'readme.md' }) + ) + + expect(runtime.statRuntimeFile).toHaveBeenCalledWith('id:wt-1', 'readme.md') + expect(response).toMatchObject({ ok: true, result: { isDirectory: false } }) + }) }) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index ceb8c00b13f..c97d211fec5 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -1,5 +1,8 @@ import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { createFileWatchEventBatcher } from './file-watch-event-batcher' + +let filesWatchSubscriptionSeq = 0 const WorktreeSelector = z.object({ worktree: z @@ -15,7 +18,93 @@ const FileOpen = WorktreeSelector.extend({ .pipe(z.string().min(1, 'Missing relative path')) }) -export const FILE_METHODS: RpcMethod[] = [ +const FileTreePath = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string()) +}) + +const FileWrite = FileOpen.extend({ + content: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string()) +}) + +const FileWriteBase64 = FileOpen.extend({ + contentBase64: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string()) +}) + +const FileWriteBase64Chunk = FileWriteBase64.extend({ + append: z.boolean().optional() +}) + +const FileRename = WorktreeSelector.extend({ + oldRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing source path')), + newRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing destination path')) +}) + +const FileCopy = WorktreeSelector.extend({ + sourceRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing source path')), + destinationRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing destination path')) +}) + +const FileCommitUpload = WorktreeSelector.extend({ + tempRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing temporary path')), + finalRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing final path')) +}) + +const FileDelete = FileOpen.extend({ + recursive: z.boolean().optional() +}) + +const FileSearch = WorktreeSelector.extend({ + query: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing search query')), + caseSensitive: z.boolean().optional(), + wholeWord: z.boolean().optional(), + useRegex: z.boolean().optional(), + includePattern: z.string().optional(), + excludePattern: z.string().optional(), + maxResults: z.number().int().positive().optional() +}) + +const FileListAll = WorktreeSelector.extend({ + excludePaths: z.array(z.string()).optional() +}) + +const FileUnwatch = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +export const FILE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'files.list', params: WorktreeSelector, @@ -32,5 +121,198 @@ export const FILE_METHODS: RpcMethod[] = [ params: FileOpen, handler: async (params, { runtime }) => runtime.readMobileFile(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.readPreview', + params: FileOpen, + handler: async (params, { runtime }) => + runtime.readFileExplorerPreview(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.readDir', + params: FileTreePath, + handler: async (params, { runtime }) => + runtime.readFileExplorerDir(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.write', + params: FileWrite, + handler: async (params, { runtime }) => + runtime.writeFileExplorerFile(params.worktree, params.relativePath, params.content) + }), + defineMethod({ + name: 'files.writeBase64', + params: FileWriteBase64, + handler: async (params, { runtime }) => + runtime.writeFileExplorerFileBase64( + params.worktree, + params.relativePath, + params.contentBase64 + ) + }), + defineMethod({ + name: 'files.writeBase64Chunk', + params: FileWriteBase64Chunk, + handler: async (params, { runtime }) => + runtime.writeFileExplorerFileBase64Chunk( + params.worktree, + params.relativePath, + params.contentBase64, + params.append === true + ) + }), + defineMethod({ + name: 'files.createFile', + params: FileOpen, + handler: async (params, { runtime }) => + runtime.createFileExplorerFile(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.createDir', + params: FileOpen, + handler: async (params, { runtime }) => + runtime.createFileExplorerDir(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.createDirNoClobber', + params: FileOpen, + handler: async (params, { runtime }) => + runtime.createFileExplorerDirNoClobber(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.commitUpload', + params: FileCommitUpload, + handler: async (params, { runtime }) => + runtime.commitFileExplorerUpload( + params.worktree, + params.tempRelativePath, + params.finalRelativePath + ) + }), + defineMethod({ + name: 'files.rename', + params: FileRename, + handler: async (params, { runtime }) => + runtime.renameFileExplorerPath( + params.worktree, + params.oldRelativePath, + params.newRelativePath + ) + }), + defineMethod({ + name: 'files.copy', + params: FileCopy, + handler: async (params, { runtime }) => + runtime.copyFileExplorerPath( + params.worktree, + params.sourceRelativePath, + params.destinationRelativePath + ) + }), + defineMethod({ + name: 'files.delete', + params: FileDelete, + handler: async (params, { runtime }) => + runtime.deleteFileExplorerPath(params.worktree, params.relativePath, params.recursive) + }), + defineMethod({ + name: 'files.search', + params: FileSearch, + handler: async (params, { runtime }) => + runtime.searchRuntimeFiles(params.worktree, { + query: params.query, + caseSensitive: params.caseSensitive, + wholeWord: params.wholeWord, + useRegex: params.useRegex, + includePattern: params.includePattern, + excludePattern: params.excludePattern, + maxResults: params.maxResults + }) + }), + defineMethod({ + name: 'files.listAll', + params: FileListAll, + handler: async (params, { runtime }) => + runtime.listRuntimeFiles(params.worktree, { excludePaths: params.excludePaths }) + }), + defineMethod({ + name: 'files.listMarkdownDocuments', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.listRuntimeMarkdownDocuments(params.worktree) + }), + defineMethod({ + name: 'files.stat', + params: FileTreePath, + handler: async (params, { runtime }) => + runtime.statRuntimeFile(params.worktree, params.relativePath) + }), + defineStreamingMethod({ + name: 'files.watch', + params: WorktreeSelector, + handler: async (params, { runtime, connectionId, signal }, emit) => { + const seq = ++filesWatchSubscriptionSeq + const subscriptionId = `files-watch-${connectionId ?? 'inproc'}-${seq}` + if (signal?.aborted) { + return + } + await new Promise((resolve, reject) => { + let settled = false + let unwatch: (() => void) | null = null + const eventBatcher = createFileWatchEventBatcher(params.worktree, emit) + const finish = (): void => { + if (settled) { + return + } + settled = true + signal?.removeEventListener('abort', handleAbort) + resolve() + } + const cleanup = (): void => { + eventBatcher.flush() + eventBatcher.dispose() + unwatch?.() + emit({ type: 'end' }) + finish() + } + const handleAbort = (): void => { + if (unwatch) { + cleanup() + } else { + finish() + } + } + signal?.addEventListener('abort', handleAbort, { once: true }) + void runtime + .watchFileExplorer(params.worktree, (events) => { + eventBatcher.push(events) + }) + .then((nextUnwatch) => { + if (signal?.aborted || settled) { + // Why: the connection can close while watch setup is still + // resolving. Tear down the late watcher immediately instead of + // registering cleanup on a connection that was already reaped. + nextUnwatch() + return + } + unwatch = nextUnwatch + runtime.registerSubscriptionCleanup(subscriptionId, cleanup, connectionId) + emit({ type: 'ready', subscriptionId }) + }) + .catch((error) => { + if (!settled) { + signal?.removeEventListener('abort', handleAbort) + reject(error) + } + }) + }) + } + }), + defineMethod({ + name: 'files.unwatch', + params: FileUnwatch, + handler: async (params, { runtime }) => { + runtime.cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } }) ] diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts new file mode 100644 index 00000000000..0bd8a810710 --- /dev/null +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { GIT_METHODS } from './git' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('git RPC methods', () => { + it('returns status for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + branch: 'main', + head: 'abc' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('git.status', { worktree: 'id:wt-1' })) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1') + expect(response).toMatchObject({ + ok: true, + result: { entries: [], branch: 'main' } + }) + }) + + it('returns a worktree file diff', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitDiff: vi.fn().mockResolvedValue({ + kind: 'text', + originalContent: '', + modifiedContent: 'hello', + originalIsBinary: false, + modifiedIsBinary: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.diff', { + worktree: 'id:wt-1', + filePath: 'src/index.ts', + staged: false, + compareAgainstHead: true + }) + ) + + expect(runtime.getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'src/index.ts', false, true) + expect(response).toMatchObject({ + ok: true, + result: { kind: 'text', modifiedContent: 'hello' } + }) + }) + + it('routes common mutations to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + stageRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }), + bulkUnstageRuntimeGitPaths: vi.fn().mockResolvedValue({ ok: true }), + discardRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + await dispatcher.dispatch( + makeRequest('git.stage', { worktree: 'id:wt-1', filePath: 'src/a.ts' }) + ) + await dispatcher.dispatch( + makeRequest('git.bulkUnstage', { worktree: 'id:wt-1', filePaths: ['src/a.ts', 'b.ts'] }) + ) + await dispatcher.dispatch( + makeRequest('git.discard', { worktree: 'id:wt-1', filePath: 'src/a.ts' }) + ) + + expect(runtime.stageRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts') + expect(runtime.bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['src/a.ts', 'b.ts']) + expect(runtime.discardRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts') + }) + + it('routes remote operations to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + commitRuntimeGit: vi.fn().mockResolvedValue({ success: true }), + pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }), + getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3') + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + await dispatcher.dispatch( + makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' }) + ) + await dispatcher.dispatch( + makeRequest('git.push', { + worktree: 'id:wt-1', + publish: true, + pushTarget: { remote: 'origin' } + }) + ) + const response = await dispatcher.dispatch( + makeRequest('git.remoteFileUrl', { + worktree: 'id:wt-1', + relativePath: 'src/a.ts', + line: 3 + }) + ) + + expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test') + expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' }) + expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' }) + }) + + it('rejects branch diff revisions that are not full object ids', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitBranchDiff: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.branchDiff', { + worktree: 'id:wt-1', + filePath: 'src/a.ts', + compare: { + headOid: '--output=/tmp/orca-test', + mergeBase: 'a'.repeat(40) + } + }) + ) + + expect(response.ok).toBe(false) + expect(runtime.getRuntimeGitBranchDiff).not.toHaveBeenCalled() + }) + + it('rejects branch compare refs that look like git options', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitBranchCompare: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.branchCompare', { + worktree: 'id:wt-1', + baseRef: '--output=/tmp/orca-test' + }) + ) + + expect(response.ok).toBe(false) + expect(runtime.getRuntimeGitBranchCompare).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts new file mode 100644 index 00000000000..3d4e6274046 --- /dev/null +++ b/src/main/runtime/rpc/methods/git.ts @@ -0,0 +1,175 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' + +const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +const GitFilePath = WorktreeSelector.extend({ + filePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing file path')) +}) + +const GitDiff = GitFilePath.extend({ + staged: z.boolean(), + compareAgainstHead: z.boolean().optional() +}) + +const GitBranchCompare = WorktreeSelector.extend({ + baseRef: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing base ref') + .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') + ) +}) + +const FullGitObjectId = z + .string() + .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') + +const GitBranchDiff = GitFilePath.extend({ + compare: z.object({ + baseRef: z.string().optional(), + baseOid: FullGitObjectId.optional(), + headOid: FullGitObjectId, + mergeBase: FullGitObjectId + }), + oldPath: z.string().optional() +}) + +const GitCommit = WorktreeSelector.extend({ + message: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing commit message')) +}) + +const GitBulkPaths = WorktreeSelector.extend({ + filePaths: z.array(z.string()) +}) + +const GitPush = WorktreeSelector.extend({ + publish: z.boolean().optional(), + pushTarget: z.unknown().optional() +}) + +const GitRemoteFileUrl = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing relative path')), + line: z.number().int().min(1) +}) + +export const GIT_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'git.status', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.getRuntimeGitStatus(params.worktree) + }), + defineMethod({ + name: 'git.conflictOperation', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.getRuntimeGitConflictOperation(params.worktree) + }), + defineMethod({ + name: 'git.diff', + params: GitDiff, + handler: async (params, { runtime }) => + runtime.getRuntimeGitDiff( + params.worktree, + params.filePath, + params.staged, + params.compareAgainstHead + ) + }), + defineMethod({ + name: 'git.branchCompare', + params: GitBranchCompare, + handler: async (params, { runtime }) => + runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef) + }), + defineMethod({ + name: 'git.upstreamStatus', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.getRuntimeGitUpstreamStatus(params.worktree) + }), + defineMethod({ + name: 'git.fetch', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.fetchRuntimeGit(params.worktree) + }), + defineMethod({ + name: 'git.pull', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.pullRuntimeGit(params.worktree) + }), + defineMethod({ + name: 'git.push', + params: GitPush, + handler: async (params, { runtime }) => + runtime.pushRuntimeGit(params.worktree, params.publish, params.pushTarget as never) + }), + defineMethod({ + name: 'git.branchDiff', + params: GitBranchDiff, + handler: async (params, { runtime }) => + runtime.getRuntimeGitBranchDiff( + params.worktree, + params.compare, + params.filePath, + params.oldPath + ) + }), + defineMethod({ + name: 'git.commit', + params: GitCommit, + handler: async (params, { runtime }) => + runtime.commitRuntimeGit(params.worktree, params.message) + }), + defineMethod({ + name: 'git.stage', + params: GitFilePath, + handler: async (params, { runtime }) => + runtime.stageRuntimeGitPath(params.worktree, params.filePath) + }), + defineMethod({ + name: 'git.bulkStage', + params: GitBulkPaths, + handler: async (params, { runtime }) => + runtime.bulkStageRuntimeGitPaths(params.worktree, params.filePaths) + }), + defineMethod({ + name: 'git.unstage', + params: GitFilePath, + handler: async (params, { runtime }) => + runtime.unstageRuntimeGitPath(params.worktree, params.filePath) + }), + defineMethod({ + name: 'git.bulkUnstage', + params: GitBulkPaths, + handler: async (params, { runtime }) => + runtime.bulkUnstageRuntimeGitPaths(params.worktree, params.filePaths) + }), + defineMethod({ + name: 'git.discard', + params: GitFilePath, + handler: async (params, { runtime }) => + runtime.discardRuntimeGitPath(params.worktree, params.filePath) + }), + defineMethod({ + name: 'git.remoteFileUrl', + params: GitRemoteFileUrl, + handler: async (params, { runtime }) => + runtime.getRuntimeGitRemoteFileUrl(params.worktree, params.relativePath, params.line) + }) +] diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts new file mode 100644 index 00000000000..bcd32235050 --- /dev/null +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -0,0 +1,519 @@ +/* eslint-disable max-lines -- Why: runtime GitHub RPC methods share one dispatcher suite so repo-scoped and Project-scoped contract coverage cannot drift. */ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { GITHUB_METHODS } from './github' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('github RPC methods', () => { + it('resolves the repo slug on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoSlug: vi.fn().mockResolvedValue({ owner: 'acme', repo: 'orca' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('github.repoSlug', { repo: 'repo-1' })) + + expect(runtime.getRepoSlug).toHaveBeenCalledWith('repo-1') + expect(response).toMatchObject({ + ok: true, + result: { owner: 'acme', repo: 'orca' } + }) + }) + + it('fetches GitHub rate limits on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getGitHubRateLimit: vi.fn().mockResolvedValue({ ok: true, snapshot: { core: {} } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('github.rateLimit', { force: true })) + + expect(runtime.getGitHubRateLimit).toHaveBeenCalledWith({ force: true }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('lists work items on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRepoWorkItems: vi.fn().mockResolvedValue({ items: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.listWorkItems', { repo: 'repo-1', limit: 10, query: 'is:pr' }) + ) + + expect(runtime.listRepoWorkItems).toHaveBeenCalledWith('repo-1', 10, 'is:pr', undefined) + expect(response).toMatchObject({ ok: true, result: { items: [] } }) + }) + + it('looks up a single work item on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoWorkItem: vi.fn().mockResolvedValue({ number: 12, type: 'pr' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.workItem', { repo: 'repo-1', number: 12, type: 'pr' }) + ) + + expect(runtime.getRepoWorkItem).toHaveBeenCalledWith('repo-1', 12, 'pr') + expect(response).toMatchObject({ ok: true, result: { number: 12, type: 'pr' } }) + }) + + it('looks up a single work item by explicit owner/repo on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoWorkItemByOwnerRepo: vi.fn().mockResolvedValue({ number: 12, type: 'pr' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.workItemByOwnerRepo', { + repo: 'repo-1', + owner: 'acme', + ownerRepo: 'orca', + number: 12, + type: 'pr' + }) + ) + + expect(runtime.getRepoWorkItemByOwnerRepo).toHaveBeenCalledWith( + 'repo-1', + { owner: 'acme', repo: 'orca' }, + 12, + 'pr' + ) + expect(response).toMatchObject({ ok: true, result: { number: 12, type: 'pr' } }) + }) + + it('fetches work item details on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoWorkItemDetails: vi.fn().mockResolvedValue({ body: 'Details' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.workItemDetails', { repo: 'repo-1', number: 12, type: 'issue' }) + ) + + expect(runtime.getRepoWorkItemDetails).toHaveBeenCalledWith('repo-1', 12, 'issue') + expect(response).toMatchObject({ ok: true, result: { body: 'Details' } }) + }) + + it('counts work items on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + countRepoWorkItems: vi.fn().mockResolvedValue(3) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.countWorkItems', { repo: 'repo-1', query: 'is:open' }) + ) + + expect(runtime.countRepoWorkItems).toHaveBeenCalledWith('repo-1', 'is:open') + expect(response).toMatchObject({ ok: true, result: 3 }) + }) + + it('lists repo issue metadata on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRepoLabels: vi.fn().mockResolvedValue(['bug']), + listRepoAssignableUsers: vi.fn().mockResolvedValue([{ login: 'octo' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const labels = await dispatcher.dispatch(makeRequest('github.listLabels', { repo: 'repo-1' })) + const users = await dispatcher.dispatch( + makeRequest('github.listAssignableUsers', { repo: 'repo-1' }) + ) + + expect(runtime.listRepoLabels).toHaveBeenCalledWith('repo-1') + expect(runtime.listRepoAssignableUsers).toHaveBeenCalledWith('repo-1') + expect(labels).toMatchObject({ ok: true, result: ['bug'] }) + expect(users).toMatchObject({ ok: true, result: [{ login: 'octo' }] }) + }) + + it('fetches PR checks on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoPRChecks: vi.fn().mockResolvedValue([]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.prChecks', { + repo: 'repo-1', + prNumber: 7, + headSha: 'abc123', + noCache: true + }) + ) + + expect(runtime.getRepoPRChecks).toHaveBeenCalledWith('repo-1', 7, 'abc123', { + noCache: true + }) + expect(response).toMatchObject({ ok: true, result: [] }) + }) + + it('fetches PR file contents on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoPRFileContents: vi.fn().mockResolvedValue({ + original: '', + modified: 'new', + originalIsBinary: false, + modifiedIsBinary: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.prFileContents', { + repo: 'repo-1', + prNumber: 7, + path: 'src/app.ts', + status: 'modified', + headSha: 'head', + baseSha: 'base' + }) + ) + + expect(runtime.getRepoPRFileContents).toHaveBeenCalledWith('repo-1', { + prNumber: 7, + path: 'src/app.ts', + oldPath: undefined, + status: 'modified', + headSha: 'head', + baseSha: 'base' + }) + expect(response).toMatchObject({ ok: true, result: { modified: 'new' } }) + }) + + it('resolves review threads on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveRepoReviewThread: vi.fn().mockResolvedValue(true) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.resolveReviewThread', { + repo: 'repo-1', + threadId: 'PRRT_1', + resolve: true + }) + ) + + expect(runtime.resolveRepoReviewThread).toHaveBeenCalledWith('repo-1', 'PRRT_1', true) + expect(response).toMatchObject({ ok: true, result: true }) + }) + + it('updates PR titles on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepoPRTitle: vi.fn().mockResolvedValue(true) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.updatePRTitle', { + repo: 'repo-1', + prNumber: 7, + title: 'New title' + }) + ) + + expect(runtime.updateRepoPRTitle).toHaveBeenCalledWith('repo-1', 7, 'New title') + expect(response).toMatchObject({ ok: true, result: true }) + }) + + it('merges PRs on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + mergeRepoPR: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.mergePR', { + repo: 'repo-1', + prNumber: 7, + method: 'squash' + }) + ) + + expect(runtime.mergeRepoPR).toHaveBeenCalledWith('repo-1', 7, 'squash') + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('creates issues on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 3, url: 'https://gh/3' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.createIssue', { + repo: 'repo-1', + title: 'Bug', + body: 'Body' + }) + ) + + expect(runtime.createRepoIssue).toHaveBeenCalledWith('repo-1', 'Bug', 'Body') + expect(response).toMatchObject({ ok: true, result: { ok: true, number: 3 } }) + }) + + it('updates issues on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepoIssue: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.updateIssue', { + repo: 'repo-1', + number: 3, + updates: { state: 'closed', addLabels: ['bug'] } + }) + ) + + expect(runtime.updateRepoIssue).toHaveBeenCalledWith('repo-1', 3, { + state: 'closed', + addLabels: ['bug'] + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('adds issue comments on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + addRepoIssueComment: vi.fn().mockResolvedValue({ ok: true, comment: { id: 1 } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.addIssueComment', { + repo: 'repo-1', + number: 3, + body: 'Looks good', + type: 'pr' + }) + ) + + expect(runtime.addRepoIssueComment).toHaveBeenCalledWith('repo-1', 3, 'Looks good') + expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 1 } } }) + }) + + it('adds PR review comments on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + addRepoPRReviewComment: vi.fn().mockResolvedValue({ ok: true, comment: { id: 2 } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.addPRReviewComment', { + repo: 'repo-1', + prNumber: 7, + commitId: 'head', + path: 'src/app.ts', + line: 12, + startLine: 10, + body: 'Please tweak' + }) + ) + + expect(runtime.addRepoPRReviewComment).toHaveBeenCalledWith('repo-1', { + prNumber: 7, + commitId: 'head', + path: 'src/app.ts', + line: 12, + startLine: 10, + body: 'Please tweak' + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 2 } } }) + }) + + it('adds PR review comment replies on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + addRepoPRReviewCommentReply: vi.fn().mockResolvedValue({ ok: true, comment: { id: 4 } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.addPRReviewCommentReply', { + repo: 'repo-1', + prNumber: 7, + commentId: 2, + body: 'Done', + threadId: 'PRRT_1', + path: 'src/app.ts', + line: 12 + }) + ) + + expect(runtime.addRepoPRReviewCommentReply).toHaveBeenCalledWith('repo-1', { + prNumber: 7, + commentId: 2, + body: 'Done', + threadId: 'PRRT_1', + path: 'src/app.ts', + line: 12 + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 4 } } }) + }) + + it('fetches GitHub project views on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listGitHubProjectViews: vi.fn().mockResolvedValue({ ok: true, views: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.project.listViews', { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1 + }) + ) + + expect(runtime.listGitHubProjectViews).toHaveBeenCalledWith({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1 + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, views: [] } }) + }) + + it('lists slug-addressed issue metadata on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listGitHubLabelsBySlug: vi.fn().mockResolvedValue({ ok: true, labels: ['bug'] }), + listGitHubAssignableUsersBySlug: vi + .fn() + .mockResolvedValue({ ok: true, users: [{ login: 'octo' }] }), + listGitHubIssueTypesBySlug: vi.fn().mockResolvedValue({ ok: true, types: [{ id: 'it-1' }] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const labels = await dispatcher.dispatch( + makeRequest('github.project.listLabelsBySlug', { owner: 'acme', repo: 'orca' }) + ) + const users = await dispatcher.dispatch( + makeRequest('github.project.listAssignableUsersBySlug', { + owner: 'acme', + repo: 'orca', + seedLogins: ['octo'] + }) + ) + const issueTypes = await dispatcher.dispatch( + makeRequest('github.project.listIssueTypesBySlug', { owner: 'acme', repo: 'orca' }) + ) + + expect(runtime.listGitHubLabelsBySlug).toHaveBeenCalledWith({ owner: 'acme', repo: 'orca' }) + expect(runtime.listGitHubAssignableUsersBySlug).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'orca', + seedLogins: ['octo'] + }) + expect(runtime.listGitHubIssueTypesBySlug).toHaveBeenCalledWith({ owner: 'acme', repo: 'orca' }) + expect(labels).toMatchObject({ ok: true, result: { ok: true, labels: ['bug'] } }) + expect(users).toMatchObject({ ok: true, result: { ok: true, users: [{ login: 'octo' }] } }) + expect(issueTypes).toMatchObject({ ok: true, result: { ok: true, types: [{ id: 'it-1' }] } }) + }) + + it('fetches GitHub project tables on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getGitHubProjectViewTable: vi.fn().mockResolvedValue({ ok: true, data: { rows: [] } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.project.viewTable', { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1', + queryOverride: 'is:open' + }) + ) + + expect(runtime.getGitHubProjectViewTable).toHaveBeenCalledWith({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1', + viewNumber: undefined, + viewName: undefined, + queryOverride: 'is:open' + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, data: { rows: [] } } }) + }) + + it('updates GitHub project item fields on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateGitHubProjectItemField: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.project.updateItemField', { + projectId: 'project-1', + itemId: 'item-1', + fieldId: 'field-1', + value: { kind: 'text', text: 'Now' } + }) + ) + + expect(runtime.updateGitHubProjectItemField).toHaveBeenCalledWith({ + projectId: 'project-1', + itemId: 'item-1', + fieldId: 'field-1', + value: { kind: 'text', text: 'Now' } + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('updates GitHub project issue types on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateGitHubIssueTypeBySlug: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.project.updateIssueTypeBySlug', { + owner: 'acme', + repo: 'orca', + number: 9, + issueTypeId: null + }) + ) + + expect(runtime.updateGitHubIssueTypeBySlug).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'orca', + number: 9, + issueTypeId: null + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts new file mode 100644 index 00000000000..72b32e6cdd8 --- /dev/null +++ b/src/main/runtime/rpc/methods/github.ts @@ -0,0 +1,442 @@ +/* eslint-disable max-lines -- Why: GitHub runtime RPC keeps related repo, project, and mutation schemas beside their handlers so the method contract stays reviewable in one place. */ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' + +const RepoSelector = z.object({ + repo: requiredString('Missing repo selector') +}) + +const WorkItemsList = RepoSelector.extend({ + limit: OptionalFiniteNumber, + query: OptionalString, + before: OptionalString +}) + +const WorkItem = RepoSelector.extend({ + number: z.number().int().positive(), + type: z.enum(['issue', 'pr']).optional() +}) + +const WorkItemByOwnerRepo = RepoSelector.extend({ + owner: requiredString('Missing owner'), + ownerRepo: requiredString('Missing repo'), + number: z.number().int().positive(), + type: z.enum(['issue', 'pr']) +}) + +const WorkItemDetails = WorkItem + +const WorkItemsCount = RepoSelector.extend({ + query: OptionalString +}) + +const RateLimit = z.object({ + force: z.boolean().optional() +}) + +const SlugRepo = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo') +}) + +const SlugAssignableUsers = SlugRepo.extend({ + seedLogins: z.array(z.string()).optional() +}) + +const PrForBranch = RepoSelector.extend({ + branch: requiredString('Missing branch'), + linkedPRNumber: z.number().int().positive().nullable().optional() +}) + +const Issue = RepoSelector.extend({ + number: z.number().int().positive() +}) + +const PullRequest = RepoSelector.extend({ + prNumber: z.number().int().positive(), + noCache: z.boolean().optional() +}) + +const PullRequestChecks = PullRequest.extend({ + headSha: OptionalString +}) + +const PullRequestFileContents = RepoSelector.extend({ + prNumber: z.number().int().positive(), + path: requiredString('Missing file path'), + oldPath: OptionalString, + status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), + headSha: requiredString('Missing head SHA'), + baseSha: requiredString('Missing base SHA') +}) + +const ReviewThread = RepoSelector.extend({ + threadId: requiredString('Missing thread ID'), + resolve: z.boolean() +}) + +const UpdatePrTitle = RepoSelector.extend({ + prNumber: z.number().int().positive(), + title: requiredString('Missing title') +}) + +const MergePr = RepoSelector.extend({ + prNumber: z.number().int().positive(), + method: z.enum(['merge', 'squash', 'rebase']).optional() +}) + +const CreateIssue = RepoSelector.extend({ + title: requiredString('Missing title'), + body: z.string() +}) + +const IssueUpdate = z.object({ + state: z.enum(['open', 'closed']).optional(), + title: OptionalString, + body: OptionalString, + addLabels: z.array(z.string()).optional(), + removeLabels: z.array(z.string()).optional(), + addAssignees: z.array(z.string()).optional(), + removeAssignees: z.array(z.string()).optional() +}) + +const UpdateIssue = RepoSelector.extend({ + number: z.number().int().positive(), + updates: IssueUpdate +}) + +const IssueComment = RepoSelector.extend({ + number: z.number().int().positive(), + body: requiredString('Comment body required'), + type: z.enum(['issue', 'pr']).optional() +}) + +const PRReviewComment = RepoSelector.extend({ + prNumber: z.number().int().positive(), + commitId: requiredString('Missing PR head SHA'), + path: requiredString('File path required'), + line: z.number().int().positive(), + startLine: z.number().int().positive().optional(), + body: requiredString('Comment body required') +}) + +const PRReviewCommentReply = RepoSelector.extend({ + prNumber: z.number().int().positive(), + commentId: z.number().int().positive(), + body: requiredString('Comment body required'), + threadId: OptionalString, + path: OptionalString, + line: z.number().int().positive().optional() +}) + +const ProjectOwnerType = z.enum(['organization', 'user']) + +const ProjectViewTable = z.object({ + owner: requiredString('Missing owner'), + ownerType: ProjectOwnerType, + projectNumber: z.number().int().positive(), + viewId: OptionalString, + viewNumber: z.number().int().positive().optional(), + viewName: OptionalString, + queryOverride: OptionalString +}) + +const ProjectRef = z.object({ + input: requiredString('Missing project reference') +}) + +const ProjectViews = z.object({ + owner: requiredString('Missing owner'), + ownerType: ProjectOwnerType, + projectNumber: z.number().int().positive() +}) + +const ProjectItemField = z.object({ + projectId: requiredString('Missing project ID'), + itemId: requiredString('Missing item ID'), + fieldId: requiredString('Missing field ID'), + value: z.any() +}) + +const ClearProjectItemField = z.object({ + projectId: requiredString('Missing project ID'), + itemId: requiredString('Missing item ID'), + fieldId: requiredString('Missing field ID') +}) + +const SlugIssueUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + number: z.number().int().positive(), + updates: IssueUpdate +}) + +const SlugPullRequestUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + number: z.number().int().positive(), + updates: z.object({ + title: OptionalString, + body: OptionalString + }) +}) + +const SlugIssueTypeUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + number: z.number().int().positive(), + issueTypeId: z.string().nullable() +}) + +const SlugIssueComment = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + number: z.number().int().positive(), + body: requiredString('Comment body required') +}) + +const SlugIssueCommentEdit = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + commentId: z.number().int().positive(), + body: requiredString('Comment body required') +}) + +const SlugIssueCommentDelete = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + commentId: z.number().int().positive() +}) + +export const GITHUB_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'github.repoSlug', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.getRepoSlug(params.repo) + }), + defineMethod({ + name: 'github.rateLimit', + params: RateLimit, + handler: async (params, { runtime }) => runtime.getGitHubRateLimit(params) + }), + defineMethod({ + name: 'github.listWorkItems', + params: WorkItemsList, + handler: async (params, { runtime }) => + runtime.listRepoWorkItems(params.repo, params.limit, params.query, params.before) + }), + defineMethod({ + name: 'github.countWorkItems', + params: WorkItemsCount, + handler: async (params, { runtime }) => runtime.countRepoWorkItems(params.repo, params.query) + }), + defineMethod({ + name: 'github.listLabels', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.listRepoLabels(params.repo) + }), + defineMethod({ + name: 'github.listAssignableUsers', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.listRepoAssignableUsers(params.repo) + }), + defineMethod({ + name: 'github.workItem', + params: WorkItem, + handler: async (params, { runtime }) => + runtime.getRepoWorkItem(params.repo, params.number, params.type) + }), + defineMethod({ + name: 'github.workItemByOwnerRepo', + params: WorkItemByOwnerRepo, + handler: async (params, { runtime }) => + runtime.getRepoWorkItemByOwnerRepo( + params.repo, + { owner: params.owner, repo: params.ownerRepo }, + params.number, + params.type + ) + }), + defineMethod({ + name: 'github.workItemDetails', + params: WorkItemDetails, + handler: async (params, { runtime }) => + runtime.getRepoWorkItemDetails(params.repo, params.number, params.type) + }), + defineMethod({ + name: 'github.prForBranch', + params: PrForBranch, + handler: async (params, { runtime }) => + runtime.getRepoPRForBranch(params.repo, params.branch, params.linkedPRNumber) + }), + defineMethod({ + name: 'github.issue', + params: Issue, + handler: async (params, { runtime }) => runtime.getRepoIssue(params.repo, params.number) + }), + defineMethod({ + name: 'github.prChecks', + params: PullRequestChecks, + handler: async (params, { runtime }) => + runtime.getRepoPRChecks(params.repo, params.prNumber, params.headSha, { + noCache: params.noCache + }) + }), + defineMethod({ + name: 'github.prComments', + params: PullRequest, + handler: async (params, { runtime }) => + runtime.getRepoPRComments(params.repo, params.prNumber, { noCache: params.noCache }) + }), + defineMethod({ + name: 'github.prFileContents', + params: PullRequestFileContents, + handler: async (params, { runtime }) => + runtime.getRepoPRFileContents(params.repo, { + prNumber: params.prNumber, + path: params.path, + oldPath: params.oldPath, + status: params.status, + headSha: params.headSha, + baseSha: params.baseSha + }) + }), + defineMethod({ + name: 'github.resolveReviewThread', + params: ReviewThread, + handler: async (params, { runtime }) => + runtime.resolveRepoReviewThread(params.repo, params.threadId, params.resolve) + }), + defineMethod({ + name: 'github.updatePRTitle', + params: UpdatePrTitle, + handler: async (params, { runtime }) => + runtime.updateRepoPRTitle(params.repo, params.prNumber, params.title) + }), + defineMethod({ + name: 'github.mergePR', + params: MergePr, + handler: async (params, { runtime }) => + runtime.mergeRepoPR(params.repo, params.prNumber, params.method) + }), + defineMethod({ + name: 'github.createIssue', + params: CreateIssue, + handler: async (params, { runtime }) => + runtime.createRepoIssue(params.repo, params.title, params.body) + }), + defineMethod({ + name: 'github.updateIssue', + params: UpdateIssue, + handler: async (params, { runtime }) => + runtime.updateRepoIssue(params.repo, params.number, params.updates) + }), + defineMethod({ + name: 'github.addIssueComment', + params: IssueComment, + handler: async (params, { runtime }) => + runtime.addRepoIssueComment(params.repo, params.number, params.body) + }), + defineMethod({ + name: 'github.addPRReviewComment', + params: PRReviewComment, + handler: async (params, { runtime }) => + runtime.addRepoPRReviewComment(params.repo, { + prNumber: params.prNumber, + commitId: params.commitId, + path: params.path, + line: params.line, + startLine: params.startLine, + body: params.body + }) + }), + defineMethod({ + name: 'github.addPRReviewCommentReply', + params: PRReviewCommentReply, + handler: async (params, { runtime }) => + runtime.addRepoPRReviewCommentReply(params.repo, { + prNumber: params.prNumber, + commentId: params.commentId, + body: params.body, + threadId: params.threadId, + path: params.path, + line: params.line + }) + }), + defineMethod({ + name: 'github.project.listAccessible', + params: z.object({}), + handler: async (_params, { runtime }) => runtime.listGitHubProjects() + }), + defineMethod({ + name: 'github.project.listLabelsBySlug', + params: SlugRepo, + handler: async (params, { runtime }) => runtime.listGitHubLabelsBySlug(params) + }), + defineMethod({ + name: 'github.project.listAssignableUsersBySlug', + params: SlugAssignableUsers, + handler: async (params, { runtime }) => runtime.listGitHubAssignableUsersBySlug(params) + }), + defineMethod({ + name: 'github.project.listIssueTypesBySlug', + params: SlugRepo, + handler: async (params, { runtime }) => runtime.listGitHubIssueTypesBySlug(params) + }), + defineMethod({ + name: 'github.project.resolveRef', + params: ProjectRef, + handler: async (params, { runtime }) => runtime.resolveGitHubProjectRef(params) + }), + defineMethod({ + name: 'github.project.listViews', + params: ProjectViews, + handler: async (params, { runtime }) => runtime.listGitHubProjectViews(params) + }), + defineMethod({ + name: 'github.project.viewTable', + params: ProjectViewTable, + handler: async (params, { runtime }) => runtime.getGitHubProjectViewTable(params) + }), + defineMethod({ + name: 'github.project.updateItemField', + params: ProjectItemField, + handler: async (params, { runtime }) => runtime.updateGitHubProjectItemField(params) + }), + defineMethod({ + name: 'github.project.clearItemField', + params: ClearProjectItemField, + handler: async (params, { runtime }) => runtime.clearGitHubProjectItemField(params) + }), + defineMethod({ + name: 'github.project.updateIssueBySlug', + params: SlugIssueUpdate, + handler: async (params, { runtime }) => runtime.updateGitHubIssueBySlug(params) + }), + defineMethod({ + name: 'github.project.updatePullRequestBySlug', + params: SlugPullRequestUpdate, + handler: async (params, { runtime }) => runtime.updateGitHubPullRequestBySlug(params) + }), + defineMethod({ + name: 'github.project.updateIssueTypeBySlug', + params: SlugIssueTypeUpdate, + handler: async (params, { runtime }) => runtime.updateGitHubIssueTypeBySlug(params) + }), + defineMethod({ + name: 'github.project.addIssueCommentBySlug', + params: SlugIssueComment, + handler: async (params, { runtime }) => runtime.addGitHubIssueCommentBySlug(params) + }), + defineMethod({ + name: 'github.project.updateIssueCommentBySlug', + params: SlugIssueCommentEdit, + handler: async (params, { runtime }) => runtime.updateGitHubIssueCommentBySlug(params) + }), + defineMethod({ + name: 'github.project.deleteIssueCommentBySlug', + params: SlugIssueCommentDelete, + handler: async (params, { runtime }) => runtime.deleteGitHubIssueCommentBySlug(params) + }) +] diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts new file mode 100644 index 00000000000..df3ecc0f544 --- /dev/null +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { HOSTED_REVIEW_METHODS } from './hosted-review' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('hosted review RPC methods', () => { + it('fetches branch review status on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getHostedReviewForBranch: vi.fn().mockResolvedValue({ + provider: 'github', + number: 12, + title: 'Feature', + state: 'open', + url: 'https://github.com/acme/orca/pull/12', + status: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'MERGEABLE' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: HOSTED_REVIEW_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('hostedReview.forBranch', { + repo: 'C:\\repo', + branch: 'feature/windows', + linkedGitHubPR: 12 + }) + ) + + expect(runtime.getHostedReviewForBranch).toHaveBeenCalledWith({ + repoSelector: 'C:\\repo', + branch: 'feature/windows', + linkedGitHubPR: 12, + linkedGitLabMR: null, + linkedBitbucketPR: null + }) + expect(response).toMatchObject({ + ok: true, + result: { provider: 'github', number: 12 } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts new file mode 100644 index 00000000000..952f00f6be4 --- /dev/null +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { requiredString } from '../schemas' + +const HostedReviewForBranch = z.object({ + repo: requiredString('Missing repo selector'), + branch: requiredString('Missing branch'), + linkedGitHubPR: z.number().int().positive().nullable().optional(), + linkedGitLabMR: z.number().int().positive().nullable().optional(), + linkedBitbucketPR: z.number().int().positive().nullable().optional() +}) + +export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'hostedReview.forBranch', + params: HostedReviewForBranch, + handler: async (params, { runtime }) => + runtime.getHostedReviewForBranch({ + repoSelector: params.repo, + branch: params.branch, + linkedGitHubPR: params.linkedGitHubPR ?? null, + linkedGitLabMR: params.linkedGitLabMR ?? null, + linkedBitbucketPR: params.linkedBitbucketPR ?? null + }) + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 69a4caccdf2..9cbc55d3435 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -12,6 +12,10 @@ import { ACCOUNT_METHODS } from './accounts' import { COMPUTER_METHODS } from './computer' import { SESSION_TAB_METHODS } from './session-tabs' import { FILE_METHODS } from './files' +import { GIT_METHODS } from './git' +import { GITHUB_METHODS } from './github' +import { HOSTED_REVIEW_METHODS } from './hosted-review' +import { LINEAR_METHODS } from './linear' import { NOTE_METHODS } from './notes' import { SPEECH_METHODS } from './speech' @@ -32,6 +36,10 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...COMPUTER_METHODS, ...SESSION_TAB_METHODS, ...FILE_METHODS, + ...GIT_METHODS, + ...GITHUB_METHODS, + ...HOSTED_REVIEW_METHODS, + ...LINEAR_METHODS, ...NOTE_METHODS, ...SPEECH_METHODS ] diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts new file mode 100644 index 00000000000..ec95e5525b2 --- /dev/null +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { LINEAR_METHODS } from './linear' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('linear RPC methods', () => { + it('routes Linear account methods to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + linearStatus: vi.fn().mockResolvedValue({ connected: true, viewer: null }), + linearTestConnection: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }), + linearConnect: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }), + linearDisconnect: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS }) + + await dispatcher.dispatch(makeRequest('linear.status')) + await dispatcher.dispatch(makeRequest('linear.testConnection')) + await dispatcher.dispatch(makeRequest('linear.connect', { apiKey: 'lin_api_key' })) + await dispatcher.dispatch(makeRequest('linear.disconnect')) + + expect(runtime.linearStatus).toHaveBeenCalled() + expect(runtime.linearTestConnection).toHaveBeenCalled() + expect(runtime.linearConnect).toHaveBeenCalledWith('lin_api_key') + expect(runtime.linearDisconnect).toHaveBeenCalled() + }) + + it('routes Linear issue queries and mutations to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + linearSearchIssues: vi.fn().mockResolvedValue([{ id: 'issue-1' }]), + linearListIssues: vi.fn().mockResolvedValue([{ id: 'issue-2' }]), + linearGetIssue: vi.fn().mockResolvedValue({ id: 'issue-3' }), + linearCreateIssue: vi.fn().mockResolvedValue({ ok: true, id: 'issue-4' }), + linearUpdateIssue: vi.fn().mockResolvedValue({ ok: true }), + linearAddIssueComment: vi.fn().mockResolvedValue({ ok: true, id: 'comment-1' }), + linearIssueComments: vi.fn().mockResolvedValue([{ id: 'comment-2' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS }) + + await dispatcher.dispatch(makeRequest('linear.searchIssues', { query: 'bug', limit: 30 })) + await dispatcher.dispatch(makeRequest('linear.listIssues', { filter: 'assigned', limit: 20 })) + await dispatcher.dispatch(makeRequest('linear.getIssue', { id: 'issue-3' })) + await dispatcher.dispatch( + makeRequest('linear.createIssue', { + teamId: 'team-1', + title: 'Fix bug', + description: 'Details' + }) + ) + await dispatcher.dispatch( + makeRequest('linear.updateIssue', { + id: 'issue-3', + updates: { stateId: 'state-1', assigneeId: null, priority: 2, labelIds: ['label-1'] } + }) + ) + await dispatcher.dispatch( + makeRequest('linear.addIssueComment', { issueId: 'issue-3', body: 'Looks good' }) + ) + await dispatcher.dispatch(makeRequest('linear.issueComments', { issueId: 'issue-3' })) + + expect(runtime.linearSearchIssues).toHaveBeenCalledWith('bug', 30) + expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20) + expect(runtime.linearGetIssue).toHaveBeenCalledWith('issue-3') + expect(runtime.linearCreateIssue).toHaveBeenCalledWith('team-1', 'Fix bug', 'Details') + expect(runtime.linearUpdateIssue).toHaveBeenCalledWith('issue-3', { + stateId: 'state-1', + assigneeId: null, + priority: 2, + labelIds: ['label-1'] + }) + expect(runtime.linearAddIssueComment).toHaveBeenCalledWith('issue-3', 'Looks good') + expect(runtime.linearIssueComments).toHaveBeenCalledWith('issue-3') + }) + + it('routes Linear metadata requests to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]), + linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]), + linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]), + linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS }) + + await dispatcher.dispatch(makeRequest('linear.listTeams')) + await dispatcher.dispatch(makeRequest('linear.teamStates', { teamId: 'team-1' })) + await dispatcher.dispatch(makeRequest('linear.teamLabels', { teamId: 'team-1' })) + await dispatcher.dispatch(makeRequest('linear.teamMembers', { teamId: 'team-1' })) + + expect(runtime.linearListTeams).toHaveBeenCalled() + expect(runtime.linearTeamStates).toHaveBeenCalledWith('team-1') + expect(runtime.linearTeamLabels).toHaveBeenCalledWith('team-1') + expect(runtime.linearTeamMembers).toHaveBeenCalledWith('team-1') + }) +}) diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts new file mode 100644 index 00000000000..29bdcf488f8 --- /dev/null +++ b/src/main/runtime/rpc/methods/linear.ts @@ -0,0 +1,136 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' + +const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const + +const Connect = z.object({ + apiKey: requiredString('Invalid API key') +}) + +const SearchIssues = z.object({ + query: requiredString('Missing query'), + limit: OptionalFiniteNumber +}) + +const ListIssues = z + .object({ + filter: z.enum(VALID_FILTERS).optional(), + limit: OptionalFiniteNumber + }) + .optional() + +const CreateIssue = z.object({ + teamId: requiredString('Team ID is required'), + title: requiredString('Title is required'), + description: OptionalString +}) + +const IssueId = z.object({ + id: requiredString('Issue ID is required') +}) + +const IssueComment = z.object({ + issueId: requiredString('Issue ID is required'), + body: requiredString('Comment body is required') +}) + +const TeamId = z.object({ + teamId: requiredString('Team ID is required') +}) + +const IssueUpdate = z.object({ + id: requiredString('Issue ID is required'), + updates: z.object({ + stateId: OptionalString, + title: OptionalString, + assigneeId: z.union([z.string(), z.null()]).optional(), + priority: z.number().int().min(0).max(4).optional(), + labelIds: z.array(z.string()).optional() + }) +}) + +export const LINEAR_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'linear.connect', + params: Connect, + handler: async (params, { runtime }) => runtime.linearConnect(params.apiKey.trim()) + }), + defineMethod({ + name: 'linear.disconnect', + params: null, + handler: async (_params, { runtime }) => runtime.linearDisconnect() + }), + defineMethod({ + name: 'linear.status', + params: null, + handler: async (_params, { runtime }) => runtime.linearStatus() + }), + defineMethod({ + name: 'linear.testConnection', + params: null, + handler: async (_params, { runtime }) => runtime.linearTestConnection() + }), + defineMethod({ + name: 'linear.searchIssues', + params: SearchIssues, + handler: async (params, { runtime }) => runtime.linearSearchIssues(params.query, params.limit) + }), + defineMethod({ + name: 'linear.listIssues', + params: ListIssues, + handler: async (params, { runtime }) => runtime.linearListIssues(params?.filter, params?.limit) + }), + defineMethod({ + name: 'linear.createIssue', + params: CreateIssue, + handler: async (params, { runtime }) => + runtime.linearCreateIssue( + params.teamId.trim(), + params.title.trim(), + params.description?.trim() || undefined + ) + }), + defineMethod({ + name: 'linear.getIssue', + params: IssueId, + handler: async (params, { runtime }) => runtime.linearGetIssue(params.id.trim()) + }), + defineMethod({ + name: 'linear.updateIssue', + params: IssueUpdate, + handler: async (params, { runtime }) => + runtime.linearUpdateIssue(params.id.trim(), params.updates) + }), + defineMethod({ + name: 'linear.addIssueComment', + params: IssueComment, + handler: async (params, { runtime }) => + runtime.linearAddIssueComment(params.issueId.trim(), params.body.trim()) + }), + defineMethod({ + name: 'linear.issueComments', + params: z.object({ issueId: requiredString('Issue ID is required') }), + handler: async (params, { runtime }) => runtime.linearIssueComments(params.issueId.trim()) + }), + defineMethod({ + name: 'linear.listTeams', + params: null, + handler: async (_params, { runtime }) => runtime.linearListTeams() + }), + defineMethod({ + name: 'linear.teamStates', + params: TeamId, + handler: async (params, { runtime }) => runtime.linearTeamStates(params.teamId.trim()) + }), + defineMethod({ + name: 'linear.teamLabels', + params: TeamId, + handler: async (params, { runtime }) => runtime.linearTeamLabels(params.teamId.trim()) + }), + defineMethod({ + name: 'linear.teamMembers', + params: TeamId, + handler: async (params, { runtime }) => runtime.linearTeamMembers(params.teamId.trim()) + }) +] diff --git a/src/main/runtime/rpc/methods/notes.test.ts b/src/main/runtime/rpc/methods/notes.test.ts new file mode 100644 index 00000000000..35c56129f5e --- /dev/null +++ b/src/main/runtime/rpc/methods/notes.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { NOTE_METHODS } from './notes' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('notes RPC methods', () => { + it('routes note reads through the selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }), + showNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS }) + + await dispatcher.dispatch(makeRequest('note.list', { worktree: 'id:wt-1', limit: 50 })) + await dispatcher.dispatch(makeRequest('note.show', { worktree: 'id:wt-1', note: 'note-1' })) + + expect(runtime.listNotes).toHaveBeenCalledWith({ worktreeSelector: 'id:wt-1', limit: 50 }) + expect(runtime.showNote).toHaveBeenCalledWith({ + worktreeSelector: 'id:wt-1', + note: 'note-1' + }) + }) + + it('routes note mutations through the selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createNote: vi.fn().mockResolvedValue({ note: { id: 'created' }, linkKind: 'active' }), + saveNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: 'active' }), + renameNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }), + deleteNote: vi.fn().mockResolvedValue({ noteId: 'note-1', projectId: 'repo-1' }), + appendNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }), + linkNote: vi.fn().mockResolvedValue({ + noteId: 'note-1', + projectId: 'repo-1', + worktreeId: 'wt-1', + kind: 'active', + createdAt: 'now' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS }) + + await dispatcher.dispatch( + makeRequest('note.create', { + worktree: 'id:wt-1', + title: 'Plan', + bodyMarkdown: 'body', + makeActive: true + }) + ) + await dispatcher.dispatch( + makeRequest('note.save', { + worktree: 'id:wt-1', + note: 'note-1', + title: 'Plan v2', + bodyMarkdown: 'updated', + revision: 2, + makeActive: true + }) + ) + await dispatcher.dispatch( + makeRequest('note.rename', { worktree: 'id:wt-1', note: 'note-1', title: 'Renamed' }) + ) + await dispatcher.dispatch(makeRequest('note.delete', { worktree: 'id:wt-1', note: 'note-1' })) + await dispatcher.dispatch( + makeRequest('note.append', { + worktree: 'id:wt-1', + note: 'note-1', + bodyMarkdown: 'more', + makeActive: true + }) + ) + await dispatcher.dispatch( + makeRequest('note.link', { worktree: 'id:wt-1', note: 'note-1', kind: 'active' }) + ) + + expect(runtime.createNote).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeSelector: 'id:wt-1', + title: 'Plan', + bodyMarkdown: 'body', + makeActive: true + }) + ) + expect(runtime.saveNote).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeSelector: 'id:wt-1', + note: 'note-1', + title: 'Plan v2', + bodyMarkdown: 'updated', + revision: 2, + makeActive: true + }) + ) + expect(runtime.renameNote).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeSelector: 'id:wt-1', + note: 'note-1', + title: 'Renamed' + }) + ) + expect(runtime.deleteNote).toHaveBeenCalledWith({ + worktreeSelector: 'id:wt-1', + note: 'note-1' + }) + expect(runtime.appendNote).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeSelector: 'id:wt-1', + note: 'note-1', + bodyMarkdown: 'more', + makeActive: true + }) + ) + expect(runtime.linkNote).toHaveBeenCalledWith({ + worktreeSelector: 'id:wt-1', + note: 'note-1', + kind: 'active' + }) + }) + + it('routes panel state and search through the selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + searchNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }), + resolveNotesPanelOpenStateForWorktree: vi.fn().mockResolvedValue({ state: 'emptyDraft' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS }) + + await dispatcher.dispatch( + makeRequest('note.search', { worktree: 'id:wt-1', query: 'todo', limit: 20 }) + ) + await dispatcher.dispatch(makeRequest('note.panelState', { worktree: 'id:wt-1' })) + + expect(runtime.searchNotes).toHaveBeenCalledWith({ + worktreeSelector: 'id:wt-1', + query: 'todo', + limit: 20 + }) + expect(runtime.resolveNotesPanelOpenStateForWorktree).toHaveBeenCalledWith({ + worktreeSelector: 'id:wt-1' + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/notes.ts b/src/main/runtime/rpc/methods/notes.ts index a4013f76671..fc0ea173871 100644 --- a/src/main/runtime/rpc/methods/notes.ts +++ b/src/main/runtime/rpc/methods/notes.ts @@ -17,13 +17,34 @@ const NoteShowParams = NoteScopedParams.extend({ const NoteCreateParams = NoteScopedParams.extend({ title: requiredString('Missing note title'), bodyMarkdown: OptionalString, - makeActive: z.boolean().optional() + makeActive: z.boolean().optional(), + createdBySessionId: z.string().nullable().optional() +}) + +const NoteSaveParams = NoteScopedParams.extend({ + note: requiredString('Missing note selector'), + title: OptionalString, + bodyMarkdown: requiredString('Missing note body'), + revision: OptionalFiniteNumber, + makeActive: z.boolean().optional(), + updatedBySessionId: z.string().nullable().optional() +}) + +const NoteRenameParams = NoteScopedParams.extend({ + note: requiredString('Missing note selector'), + title: requiredString('Missing note title'), + updatedBySessionId: z.string().nullable().optional() +}) + +const NoteDeleteParams = NoteScopedParams.extend({ + note: requiredString('Missing note selector') }) const NoteAppendParams = NoteScopedParams.extend({ note: requiredString('Missing note selector'), bodyMarkdown: requiredString('Missing note body'), - makeActive: z.boolean().optional() + makeActive: z.boolean().optional(), + updatedBySessionId: z.string().nullable().optional() }) const NoteSearchParams = NoteScopedParams.extend({ @@ -31,6 +52,11 @@ const NoteSearchParams = NoteScopedParams.extend({ limit: OptionalFiniteNumber }) +const NoteLinkParams = NoteScopedParams.extend({ + note: requiredString('Missing note selector'), + kind: z.enum(['active', 'referenced']) +}) + export const NOTE_METHODS: readonly RpcAnyMethod[] = [ defineMethod({ name: 'note.list', @@ -58,7 +84,42 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [ worktreeSelector: params.worktree, title: params.title, bodyMarkdown: params.bodyMarkdown, - makeActive: params.makeActive + makeActive: params.makeActive, + createdBySessionId: params.createdBySessionId + }) + }), + defineMethod({ + name: 'note.save', + params: NoteSaveParams, + handler: async (params, { runtime }) => + await runtime.saveNote({ + worktreeSelector: params.worktree, + note: params.note, + title: params.title, + bodyMarkdown: params.bodyMarkdown, + revision: params.revision, + makeActive: params.makeActive, + updatedBySessionId: params.updatedBySessionId + }) + }), + defineMethod({ + name: 'note.rename', + params: NoteRenameParams, + handler: async (params, { runtime }) => + await runtime.renameNote({ + worktreeSelector: params.worktree, + note: params.note, + title: params.title, + updatedBySessionId: params.updatedBySessionId + }) + }), + defineMethod({ + name: 'note.delete', + params: NoteDeleteParams, + handler: async (params, { runtime }) => + await runtime.deleteNote({ + worktreeSelector: params.worktree, + note: params.note }) }), defineMethod({ @@ -69,7 +130,8 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [ worktreeSelector: params.worktree, note: params.note, bodyMarkdown: params.bodyMarkdown, - makeActive: params.makeActive + makeActive: params.makeActive, + updatedBySessionId: params.updatedBySessionId }) }), defineMethod({ @@ -81,5 +143,23 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [ query: params.query, limit: params.limit }) + }), + defineMethod({ + name: 'note.link', + params: NoteLinkParams, + handler: async (params, { runtime }) => + await runtime.linkNote({ + worktreeSelector: params.worktree, + note: params.note, + kind: params.kind + }) + }), + defineMethod({ + name: 'note.panelState', + params: NoteScopedParams, + handler: async (params, { runtime }) => + await runtime.resolveNotesPanelOpenStateForWorktree({ + worktreeSelector: params.worktree + }) }) ] diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 3ae1d213586..93856e98adb 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -349,6 +349,42 @@ describe('orchestration RPC methods', () => { // Must not have marked read expect(db.getUnreadMessages('b')).toHaveLength(1) }) + + it('does not mark messages read when a waiting check is aborted', async () => { + setup() + const abortController = new AbortController() + ctx = { runtime, signal: abortController.signal } + vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => { + db.insertMessage({ from: 'a', to: 'b', subject: 'arrived during close' }) + abortController.abort() + }) + + const result = (await call('orchestration.check', { + terminal: 'b', + wait: true, + timeoutMs: 100 + })) as { messages: unknown[]; count: number } + + expect(result).toEqual({ messages: [], count: 0 }) + expect(db.getUnreadMessages('b')).toHaveLength(1) + }) + + it('does not mark existing messages read when the check starts aborted', async () => { + setup() + const abortController = new AbortController() + abortController.abort() + ctx = { runtime, signal: abortController.signal } + db.insertMessage({ from: 'a', to: 'b', subject: 'already unread' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + wait: true, + timeoutMs: 100 + })) as { messages: unknown[]; count: number } + + expect(result).toEqual({ messages: [], count: 0 }) + expect(db.getUnreadMessages('b')).toHaveLength(1) + }) }) describe('orchestration.reply', () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index e1a2d68b943..0fb5a6f8680 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -241,6 +241,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ return { messages, count: messages.length } } + if (signal?.aborted) { + return { messages: [], count: 0 } + } const result = readAndReturn() if (result.count > 0 || !params.wait) { return result @@ -257,6 +260,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ timeoutMs: params.timeoutMs ?? undefined, signal }) + if (signal?.aborted) { + return { messages: [], count: 0 } + } return readAndReturn() } }), diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts new file mode 100644 index 00000000000..da7b9212215 --- /dev/null +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { REPO_METHODS } from './repo' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('repo RPC methods', () => { + it('creates a repo on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createRepo: vi.fn().mockResolvedValue({ + repo: { id: 'repo-1', path: '/srv/projects/new-app', kind: 'git' } + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.create', { + parentPath: '/srv/projects', + name: 'new-app', + kind: 'git' + }) + ) + + expect(runtime.createRepo).toHaveBeenCalledWith('/srv/projects', 'new-app', 'git') + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', path: '/srv/projects/new-app' } } + }) + }) + + it('clones a repo on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + cloneRepo: vi.fn().mockResolvedValue({ + id: 'repo-1', + path: '/srv/projects/orca', + kind: 'git' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.clone', { + url: 'https://github.com/example/orca.git', + destination: '/srv/projects' + }) + ) + + expect(runtime.cloneRepo).toHaveBeenCalledWith( + 'https://github.com/example/orca.git', + '/srv/projects' + ) + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', path: '/srv/projects/orca' } } + }) + }) + + it('routes repository hook operations to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + checkRepoHooks: vi.fn().mockResolvedValue({ + hasHooks: true, + hooks: { scripts: { setup: 'pnpm install' } }, + mayNeedUpdate: false + }), + readRepoIssueCommand: vi.fn().mockResolvedValue({ + localContent: null, + sharedContent: 'Fix {{artifact_url}}', + effectiveContent: 'Fix {{artifact_url}}', + localFilePath: '/srv/repo/.orca/issue-command', + source: 'shared' + }), + writeRepoIssueCommand: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + await dispatcher.dispatch(makeRequest('repo.hooksCheck', { repo: 'repo-1' })) + await dispatcher.dispatch(makeRequest('repo.issueCommandRead', { repo: 'repo-1' })) + await dispatcher.dispatch( + makeRequest('repo.issueCommandWrite', { + repo: 'repo-1', + content: 'Fix it' + }) + ) + + expect(runtime.checkRepoHooks).toHaveBeenCalledWith('repo-1') + expect(runtime.readRepoIssueCommand).toHaveBeenCalledWith('repo-1') + expect(runtime.writeRepoIssueCommand).toHaveBeenCalledWith('repo-1', 'Fix it') + }) +}) diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 3af1133f836..9b466a2ce6a 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -1,13 +1,25 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, requiredString } from '../schemas' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') }) const RepoPath = z.object({ - path: requiredString('Missing repo path') + path: requiredString('Missing repo path'), + kind: z.enum(['git', 'folder']).optional() +}) + +const RepoCreate = z.object({ + parentPath: requiredString('Missing parent path'), + name: requiredString('Missing repo name'), + kind: z.enum(['git', 'folder']).optional() +}) + +const RepoClone = z.object({ + url: requiredString('Missing clone URL'), + destination: requiredString('Missing clone destination') }) const RepoSetBaseRef = z.object({ @@ -15,6 +27,18 @@ const RepoSetBaseRef = z.object({ ref: requiredString('Missing base ref') }) +const RepoUpdate = RepoSelector.extend({ + updates: z.object({ + displayName: OptionalString, + badgeColor: OptionalString, + hookSettings: z.unknown().optional(), + worktreeBaseRef: OptionalString, + kind: z.enum(['git', 'folder']).optional(), + symlinkPaths: z.array(z.string()).optional(), + issueSourcePreference: z.enum(['auto', 'github', 'linear']).optional() + }) +}) + const RepoSearchRefs = z.object({ repo: requiredString('Missing repo selector'), query: z @@ -24,6 +48,14 @@ const RepoSearchRefs = z.object({ limit: OptionalFiniteNumber }) +const RepoReorder = z.object({ + orderedIds: z.array(z.string()) +}) + +const RepoIssueCommandWrite = RepoSelector.extend({ + content: z.string() +}) + export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'repo.list', @@ -33,13 +65,48 @@ export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'repo.add', params: RepoPath, - handler: async (params, { runtime }) => ({ repo: await runtime.addRepo(params.path) }) + handler: async (params, { runtime }) => ({ + repo: await runtime.addRepo(params.path, params.kind) + }) + }), + defineMethod({ + name: 'repo.create', + params: RepoCreate, + handler: async (params, { runtime }) => + runtime.createRepo(params.parentPath, params.name, params.kind) + }), + defineMethod({ + name: 'repo.clone', + params: RepoClone, + handler: async (params, { runtime }) => ({ + repo: await runtime.cloneRepo(params.url, params.destination) + }) }), defineMethod({ name: 'repo.show', params: RepoSelector, handler: async (params, { runtime }) => ({ repo: await runtime.showRepo(params.repo) }) }), + defineMethod({ + name: 'repo.update', + params: RepoUpdate, + handler: async (params, { runtime }) => ({ + repo: await runtime.updateRepo( + params.repo, + params.updates as Parameters[1] + ) + }) + }), + defineMethod({ + name: 'repo.rm', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.removeRepo(params.repo) + }), + defineMethod({ + name: 'repo.reorder', + params: RepoReorder, + handler: async (params, { runtime }) => runtime.reorderRepos(params.orderedIds) + }), defineMethod({ name: 'repo.setBaseRef', params: RepoSetBaseRef, @@ -47,6 +114,11 @@ export const REPO_METHODS: RpcMethod[] = [ repo: await runtime.setRepoBaseRef(params.repo, params.ref) }) }), + defineMethod({ + name: 'repo.baseRefDefault', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.getRepoBaseRefDefault(params.repo) + }), defineMethod({ name: 'repo.searchRefs', params: RepoSearchRefs, @@ -57,5 +129,21 @@ export const REPO_METHODS: RpcMethod[] = [ name: 'repo.hooks', params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoHooks(params.repo) + }), + defineMethod({ + name: 'repo.hooksCheck', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.checkRepoHooks(params.repo) + }), + defineMethod({ + name: 'repo.issueCommandRead', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.readRepoIssueCommand(params.repo) + }), + defineMethod({ + name: 'repo.issueCommandWrite', + params: RepoIssueCommandWrite, + handler: async (params, { runtime }) => + runtime.writeRepoIssueCommand(params.repo, params.content) }) ] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index fdf9350b99a..1fcbc4fae41 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -2,12 +2,15 @@ import { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import type { OrcaRuntimeService } from '../../orca-runtime' +import type { DriverState, OrcaRuntimeService } from '../../orca-runtime' import { TerminalStreamOpcode, + decodeTerminalStreamJson, + decodeTerminalStreamText, encodeTerminalStreamFrame, encodeTerminalStreamJson, - encodeTerminalStreamText + encodeTerminalStreamText, + type TerminalStreamFrame } from '../../../../shared/terminal-stream-protocol' // Why: when a mobile client subscribes the server resizes the PTY to phone @@ -19,6 +22,9 @@ import { const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000 const MOBILE_SNAPSHOT_BYTE_BUDGET = 512 * 1024 const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 +const TERMINAL_OUTPUT_FLUSH_MS = 5 +const TERMINAL_OUTPUT_BATCH_MAX_CHARS = 64 * 1024 +const TERMINAL_MULTIPLEX_PENDING_MAX_CHARS = 256 * 1024 let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -41,6 +47,126 @@ type SerializedSnapshot = { truncatedByByteBudget: boolean } | null +type TerminalViewportClient = { + id: string + type?: 'mobile' | 'desktop' +} + +type TerminalMultiplexStream = { + streamId: number + terminal: string + ptyId: string + client: TerminalViewportClient | undefined + isMobile: boolean + buffering: boolean + pendingOutput: string[] + pendingOutputChars: number + outputBatcher: ReturnType + unsubscribeData: () => void + unsubscribeResize: () => void + unsubscribeFit: () => void + unsubscribeDriver: () => void + unregisterBinaryHandler: () => void +} + +function createTerminalOutputBatcher(onFlush: (data: string) => void): { + push: (data: string) => void + flush: () => void + dispose: () => void +} { + let chunks: string[] = [] + let chars = 0 + let timer: ReturnType | null = null + + const clearTimer = (): void => { + if (!timer) { + return + } + clearTimeout(timer) + timer = null + } + + const flush = (): void => { + clearTimer() + if (chunks.length === 0) { + return + } + const data = chunks.length === 1 ? chunks[0]! : chunks.join('') + chunks = [] + chars = 0 + onFlush(data) + } + + return { + push(data: string): void { + if (!data) { + return + } + chunks.push(data) + chars += data.length + if (chars >= TERMINAL_OUTPUT_BATCH_MAX_CHARS) { + flush() + return + } + if (!timer) { + // Why: Paseo coalesces terminal stream output before crossing the + // network. Desktop runtime subscribers need the same burst boundary. + timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + }, + flush, + dispose(): void { + clearTimer() + chunks = [] + chars = 0 + } + } +} + +function isTerminalInputLockedForClient( + runtime: OrcaRuntimeService, + ptyId: string, + client: TerminalViewportClient | undefined +): boolean { + if (client?.type === 'mobile') { + return false + } + // Why: pre-refactor mobile builds did not send client metadata. Desktop + // callers we control now identify as desktop, so keep legacy mobile input + // working without opening the new desktop path. + if (!client) { + return false + } + return runtime.getDriver(ptyId).kind === 'mobile' +} + +function resolveMobileFloorClientId( + driver: DriverState | null, + client: TerminalViewportClient | undefined +): string | null { + if (client?.type === 'mobile') { + return client.id + } + if (!client && driver?.kind === 'mobile') { + return driver.clientId + } + return null +} + +function appendPendingMultiplexOutput(stream: TerminalMultiplexStream, data: string): void { + stream.pendingOutput.push(data) + stream.pendingOutputChars += data.length + while ( + stream.pendingOutputChars > TERMINAL_MULTIPLEX_PENDING_MAX_CHARS && + stream.pendingOutput.length > 0 + ) { + stream.pendingOutputChars -= stream.pendingOutput.shift()?.length ?? 0 + } +} + function sendSnapshotFrames( sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, options: SnapshotFrameOptions @@ -99,6 +225,20 @@ async function serializeBudgetedMobileSnapshot( return null } +async function updateViewportForClient( + runtime: OrcaRuntimeService, + ptyId: string, + client: TerminalViewportClient, + viewport: { cols: number; rows: number }, + defaultType: 'mobile' | 'desktop' +): Promise { + const type = client.type ?? defaultType + if (type === 'mobile') { + return runtime.updateMobileViewport(ptyId, client.id, viewport) + } + return runtime.updateDesktopViewport(ptyId, viewport) +} + const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) @@ -161,6 +301,11 @@ const TerminalSend = TerminalHandle.extend({ .optional() }) +const TerminalViewport = z.object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) +}) + const TerminalWait = TerminalHandle.extend({ for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { message: 'Invalid --for value. Supported: exit, tui-idle' @@ -171,6 +316,7 @@ const TerminalWait = TerminalHandle.extend({ const TerminalCreateParams = z.object({ worktree: OptionalString, command: OptionalString, + env: z.record(z.string(), z.string()).optional(), title: OptionalString, focus: z.unknown().optional() }) @@ -210,12 +356,7 @@ const TerminalSubscribe = TerminalHandle.extend({ type: z.enum(['mobile', 'desktop']).default('desktop') }) .optional(), - viewport: z - .object({ - cols: z.number().int().min(20).max(240), - rows: z.number().int().min(8).max(120) - }) - .optional(), + viewport: TerminalViewport.optional(), capabilities: z .object({ terminalBinaryStream: z.literal(1).optional() @@ -223,6 +364,19 @@ const TerminalSubscribe = TerminalHandle.extend({ .optional() }) +const TerminalMultiplex = z.object({}) + +const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ + streamId: z.number().int().min(1), + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: TerminalViewport.optional() +}) + const TerminalSetDisplayMode = TerminalHandle.extend({ // Why: 'phone' was previously a "stay at phone dims after unsubscribe" // mode that the toggle UI never produced and nothing in product @@ -317,6 +471,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ terminal: await runtime.readTerminal(params.terminal, { cursor: params.cursor }) }) }), + defineMethod({ + name: 'terminal.inspectProcess', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + process: await runtime.inspectTerminalProcess(params.terminal) + }) + }), defineMethod({ name: 'terminal.rename', params: TerminalRename, @@ -335,6 +496,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.send', params: TerminalSend, handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null + if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } const result = await runtime.sendTerminal(params.terminal, { text: params.text, enter: params.enter === true, @@ -343,12 +515,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // Why: deliberate mobile input is a take-floor action. Drives the // `* → mobile{clientId}` driver transition so the desktop banner // remounts (if previously reclaimed) and active phone-fit dims follow - // the most recent actor. Only mobile-typed callers take the floor. - if (params.client && params.client.type === 'mobile') { - const leaf = runtime.resolveLeafForHandle(params.terminal) - if (leaf?.ptyId) { - await runtime.mobileTookFloor(leaf.ptyId, params.client.id) - } + // the most recent actor. Clientless sends are old mobile builds, so use + // the current mobile driver as their compatibility identity. + const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) + if (leaf?.ptyId && mobileFloorClientId) { + await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId) } return { send: result } } @@ -369,6 +540,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => ({ terminal: await runtime.createTerminal(params.worktree, { command: params.command, + env: params.env, title: params.title, focus: params.focus === true }) @@ -452,6 +624,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } } }), + defineMethod({ + name: 'terminal.restoreFit', + params: TerminalHandle, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + return { restored: await runtime.reclaimTerminalForDesktop(leaf.ptyId) } + } + }), defineMethod({ name: 'terminal.getDisplayMode', params: TerminalHandle, @@ -470,24 +653,336 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!leaf?.ptyId) { throw new Error('no_connected_pty') } - const updated = await runtime.updateMobileViewport( + const updated = await updateViewportForClient( + runtime, leaf.ptyId, - params.client.id, - params.viewport + params.client, + params.viewport, + 'mobile' ) return { updated, seq: runtime.getLayout(leaf.ptyId)?.seq } } }), + // Why: desktop remote sessions can have dozens of panes. One streaming RPC + // owns the binary socket and routes terminal slots by streamId, mirroring + // Paseo's slot-based terminal data plane while keeping legacy subscribe as + // the compatibility fallback. + defineStreamingMethod({ + name: 'terminal.multiplex', + params: TerminalMultiplex, + handler: async ( + _params, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler }, + emit + ) => { + if (!sendBinary || !registerBinaryStreamHandler || !connectionId) { + throw new Error('binary_terminal_stream_required') + } + + let closed = false + let cursor = 0 + const streams = new Map() + let resolveMultiplex = (): void => {} + const multiplexClosed = new Promise((resolve) => { + resolveMultiplex = resolve + }) + const sendFrame = ( + streamId: number, + opcode: TerminalStreamOpcode, + payload: Uint8Array = new Uint8Array() + ): void => { + if (closed) { + return + } + sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload })) + } + const sendStreamError = (streamId: number, message: string): void => { + sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) + emit({ type: 'error', streamId, message }) + } + const detachStream = (streamId: number, emitEnd: boolean): void => { + const stream = streams.get(streamId) + if (!stream) { + return + } + stream.outputBatcher.flush() + stream.outputBatcher.dispose() + stream.unsubscribeData() + stream.unsubscribeResize() + stream.unsubscribeFit() + stream.unsubscribeDriver() + stream.unregisterBinaryHandler() + streams.delete(streamId) + if (stream.isMobile && stream.client?.id) { + runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) + } + if (emitEnd) { + emit({ type: 'end', streamId }) + } + } + const closeMultiplex = (): void => { + if (closed) { + return + } + closed = true + for (const streamId of Array.from(streams.keys())) { + detachStream(streamId, false) + } + unregisterControlHandler() + resolveMultiplex() + } + const handleSlotFrame = ( + stream: TerminalMultiplexStream, + frame: TerminalStreamFrame + ): void => { + if (closed || streams.get(stream.streamId) !== stream) { + return + } + if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { + detachStream(stream.streamId, false) + return + } + if (frame.opcode === TerminalStreamOpcode.Input) { + const text = decodeTerminalStreamText(frame.payload) + if (!text) { + return + } + if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + void runtime + .sendTerminal(stream.terminal, { text, enter: false, interrupt: false }) + .then(async () => { + if (stream.isMobile && stream.client?.id) { + await runtime.mobileTookFloor(stream.ptyId, stream.client.id) + } + }) + .catch(() => {}) + return + } + if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( + frame.payload + ) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + void updateViewportForClient( + runtime, + stream.ptyId, + stream.client, + { cols: viewport.cols, rows: viewport.rows }, + stream.isMobile ? 'mobile' : 'desktop' + ).catch(() => {}) + } + } + const handleSubscribeFrame = async (payload: Uint8Array): Promise => { + const raw = decodeTerminalStreamJson(payload) + const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw) + if (!parsed.success) { + return + } + const request = parsed.data + detachStream(request.streamId, false) + + let leaf = runtime.resolveLeafForHandle(request.terminal) + const isMobile = request.client?.type === 'mobile' + if (!leaf?.ptyId && isMobile) { + try { + const ptyId = await runtime.waitForLeafPtyId(request.terminal) + leaf = { ptyId } + } catch { + // Fall through to the explicit no_connected_pty error below. + } + } + if (!leaf?.ptyId) { + sendStreamError(request.streamId, 'no_connected_pty') + emit({ type: 'end', streamId: request.streamId }) + return + } + + const ptyId = leaf.ptyId + const stream: TerminalMultiplexStream = { + streamId: request.streamId, + terminal: request.terminal, + ptyId, + client: request.client, + isMobile, + buffering: true, + pendingOutput: [], + pendingOutputChars: 0, + outputBatcher: createTerminalOutputBatcher((data) => { + sendFrame(request.streamId, TerminalStreamOpcode.Output, encodeTerminalStreamText(data)) + }), + unsubscribeData: () => {}, + unsubscribeResize: () => {}, + unsubscribeFit: () => {}, + unsubscribeDriver: () => {}, + unregisterBinaryHandler: () => {} + } + streams.set(request.streamId, stream) + stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) => + handleSlotFrame(stream, frame) + ) + + try { + stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + if (closed || streams.get(request.streamId) !== stream) { + return + } + if (stream.buffering) { + appendPendingMultiplexOutput(stream, data) + return + } + stream.outputBatcher.push(data) + }) + + if (isMobile && request.client?.id) { + await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) + } else if (request.viewport && request.client) { + await updateViewportForClient( + runtime, + ptyId, + request.client, + request.viewport, + 'desktop' + ) + } + if (closed || streams.get(request.streamId) !== stream) { + return + } + + if (!isMobile) { + stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + emit({ + type: 'fit-override-changed', + streamId: request.streamId, + mode: event.mode, + cols: event.cols, + rows: event.rows + }) + }) + stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => { + emit({ + type: 'driver-changed', + streamId: request.streamId, + driver + }) + }) + } + + const read = await runtime.readTerminal(request.terminal) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (closed || streams.get(request.streamId) !== stream) { + return + } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const seq = runtime.getLayout(ptyId)?.seq + if (!isMobile) { + const fitOverride = runtime.getTerminalFitOverride(ptyId) + emit({ + type: 'fit-override-changed', + streamId: request.streamId, + mode: fitOverride?.mode ?? 'desktop-fit', + cols: fitOverride?.cols ?? size?.cols ?? 0, + rows: fitOverride?.rows ?? size?.rows ?? 0 + }) + emit({ + type: 'driver-changed', + streamId: request.streamId, + driver: runtime.getDriver(ptyId) + }) + } + emit({ + type: 'subscribed', + streamId: request.streamId, + terminal: request.terminal, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode, + seq, + truncated: read.truncated + }) + sendSnapshotFrames((opcode, payload) => sendFrame(request.streamId, opcode, payload), { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + displayMode, + seq, + truncated: read.truncated, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') + }) + stream.buffering = false + for (const data of stream.pendingOutput.splice(0)) { + stream.outputBatcher.push(data) + } + stream.pendingOutputChars = 0 + stream.outputBatcher.flush() + + stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { + stream.outputBatcher.flush() + sendFrame( + request.streamId, + TerminalStreamOpcode.Resized, + encodeTerminalStreamJson({ + cols: event.cols, + rows: event.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq + }) + ) + }) + void runtime + .waitForTerminal(request.terminal, { condition: 'exit' }) + .then(() => { + if (streams.get(request.streamId) === stream) { + detachStream(request.streamId, true) + } + }) + .catch(() => { + if (streams.get(request.streamId) === stream) { + detachStream(request.streamId, true) + } + }) + } catch (error) { + detachStream(request.streamId, false) + sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) + emit({ type: 'end', streamId: request.streamId }) + } + } + const unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { + if (frame.opcode !== TerminalStreamOpcode.Subscribe) { + return + } + void handleSubscribeFrame(frame.payload) + }) + + runtime.registerSubscriptionCleanup( + `terminal-multiplex:${connectionId}`, + closeMultiplex, + connectionId + ) + emit({ type: 'ready' }) + await multiplexClosed + } + }), // Why: terminal.subscribe streams live terminal output over WebSocket. // It sends initial scrollback, then live data chunks as they arrive. // Mobile clients pass client+viewport params for server-side auto-fit. defineStreamingMethod({ name: 'terminal.subscribe', params: TerminalSubscribe, - handler: async (params, { runtime, connectionId, sendBinary }, emit) => { + handler: async ( + params, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler }, + emit + ) => { let leaf = runtime.resolveLeafForHandle(params.terminal) const isMobile = params.client?.type === 'mobile' - const useBinaryStream = isMobile && params.capabilities?.terminalBinaryStream === 1 + const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) // Why: the left pane's PTY spawns asynchronously after the tab is created. // Mobile clients that subscribe before the PTY is ready would get a bare @@ -515,7 +1010,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id - if (!isMobile) { + if (!useBinaryStream) { const read = await runtime.readTerminal(params.terminal) const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) const size = runtime.getTerminalSize(ptyId) @@ -532,11 +1027,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ seq }) + // Why: desktop can have both a hidden automation watcher and a visible + // pane subscribed to the same terminal. Key by client when provided so + // one stream cannot evict the other. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal await new Promise((resolve) => { + const outputBatcher = createTerminalOutputBatcher((chunk) => { + emit({ type: 'data', chunk }) + }) const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { - emit({ type: 'data', chunk: data }) + outputBatcher.push(data) }) const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + outputBatcher.flush() emit({ type: 'fit-override-changed', mode: event.mode, @@ -545,8 +1048,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) }) runtime.registerSubscriptionCleanup( - params.terminal, + subscriptionId, () => { + outputBatcher.flush() + outputBatcher.dispose() unsubscribeData() unsubscribeFit() emit({ type: 'end' }) @@ -554,6 +1059,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }, connectionId ) + void runtime + .waitForTerminal(params.terminal, { condition: 'exit' }) + .then(() => runtime.cleanupSubscription(subscriptionId)) + .catch(() => runtime.cleanupSubscription(subscriptionId)) }) return } @@ -563,23 +1072,30 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let closed = false let buffering = true const pendingOutput: string[] = [] + let pendingOutputChars = 0 let unsubscribeData = (): void => {} let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} + let unregisterBinaryHandler = (): void => {} + let outputBatcher: ReturnType | null = null let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { resolveStream = resolve }) // Why: register cleanup before any mobile-fit or snapshot await. A phone - // can disconnect mid-subscribe; cleanup must still remove mobile presence. + // can disconnect mid-subscribe; cleanup must still remove mobile + // presence. Client-scoped ids also allow parallel desktop subscribers. const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal runtime.registerSubscriptionCleanup( subscriptionId, () => { + outputBatcher?.flush() + outputBatcher?.dispose() closed = true unsubscribeData() unsubscribeResize() unsubscribeFit() + unregisterBinaryHandler() if (isMobile && clientId) { runtime.handleMobileUnsubscribe(ptyId, clientId) } @@ -588,6 +1104,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }, connectionId ) + void runtime + .waitForTerminal(params.terminal, { condition: 'exit' }) + .then(() => runtime.cleanupSubscription(subscriptionId)) + .catch(() => runtime.cleanupSubscription(subscriptionId)) const sendFrame = ( opcode: TerminalStreamOpcode, payload: Uint8Array = new Uint8Array() @@ -597,6 +1117,52 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload })) } + outputBatcher = createTerminalOutputBatcher((data) => { + sendFrame(TerminalStreamOpcode.Output, encodeTerminalStreamText(data)) + }) + unregisterBinaryHandler = + registerBinaryStreamHandler?.(streamId, (frame) => { + if (closed) { + return + } + if (frame.opcode === TerminalStreamOpcode.Input) { + const text = decodeTerminalStreamText(frame.payload) + if (!text) { + return + } + if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + void runtime + .sendTerminal(params.terminal, { text, enter: false, interrupt: false }) + .then(async () => { + if (isMobile && clientId) { + await runtime.mobileTookFloor(ptyId, clientId) + } + }) + .catch(() => {}) + return + } + if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( + frame.payload + ) + if ( + !viewport || + typeof viewport.cols !== 'number' || + typeof viewport.rows !== 'number' + ) { + return + } + void updateViewportForClient( + runtime, + ptyId, + params.client, + { cols: viewport.cols, rows: viewport.rows }, + 'desktop' + ).catch(() => {}) + } + }) ?? (() => {}) // Server-side auto-fit: resize PTY to phone dims before serializing scrollback try { if (isMobile && clientId) { @@ -612,20 +1178,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } if (buffering) { pendingOutput.push(data) + pendingOutputChars += data.length + while ( + pendingOutputChars > TERMINAL_MULTIPLEX_PENDING_MAX_CHARS && + pendingOutput.length > 0 + ) { + pendingOutputChars -= pendingOutput.shift()?.length ?? 0 + } return } - sendBinary!( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - streamId, - seq: cursor++, - payload: encodeTerminalStreamText(data) - }) - ) + outputBatcher?.push(data) }) const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) if (closed) { return } @@ -667,20 +1233,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) buffering = false for (const item of pendingOutput.splice(0)) { - sendBinary!( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - streamId, - seq: cursor++, - payload: encodeTerminalStreamText(item) - }) - ) + outputBatcher.push(item) } + pendingOutputChars = 0 + outputBatcher.flush() unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { // Why: true PTY geometry changes should be followed by the TUI's // redraw output, not a full scrollback replay. The client resizes // xterm geometry and consumes subsequent live output on this stream. + outputBatcher?.flush() sendFrame( TerminalStreamOpcode.Resized, encodeTerminalStreamJson({ diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts new file mode 100644 index 00000000000..e0da750b5c4 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('worktree RPC methods', () => { + it('routes create options to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'feature', + baseBranch: 'origin/main', + setupDecision: 'skip', + displayName: 'Feature title', + linkedIssue: 123, + linkedPR: 456, + sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, + pushTarget: { remoteName: 'fork', branchName: 'feature' } + }) + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith({ + repoSelector: 'repo-1', + name: 'feature', + baseBranch: 'origin/main', + linkedIssue: 123, + linkedPR: 456, + comment: undefined, + displayName: 'Feature title', + sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, + pushTarget: { remoteName: 'fork', branchName: 'feature' }, + runHooks: false, + activate: false, + setupDecision: 'skip', + startup: undefined + }) + }) + + it('persists smart sort order on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + persistManagedWorktreeSortOrder: vi.fn().mockReturnValue({ updated: 2 }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.persistSortOrder', { orderedIds: ['wt-1', 'wt-2'] }) + ) + + expect(runtime.persistManagedWorktreeSortOrder).toHaveBeenCalledWith(['wt-1', 'wt-2']) + expect(response).toMatchObject({ ok: true, result: { updated: 2 } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index e3358fa731b..0dd7c5384ba 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -3,9 +3,11 @@ import { defineMethod, type RpcMethod } from '../core' import { OptionalBoolean, OptionalFiniteNumber, + OptionalPlainString, OptionalString, TriStateLinkedIssue } from '../schemas' +import { isTuiAgent } from '../../../../shared/tui-agent-config' const WorktreeListParams = z.object({ repo: OptionalString, @@ -16,6 +18,10 @@ const WorktreePsParams = z.object({ limit: OptionalFiniteNumber }) +const WorktreeSortOrder = z.object({ + orderedIds: z.array(z.string()) +}) + const WorktreeSelector = z.object({ worktree: z .unknown() @@ -31,7 +37,22 @@ const WorktreeCreate = z.object({ name: OptionalString, baseBranch: OptionalString, linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, comment: OptionalString, + displayName: OptionalString, + sparseCheckout: z + .object({ + directories: z.array(z.string()), + presetId: OptionalString + }) + .optional(), + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), runHooks: OptionalBoolean, activate: OptionalBoolean, setupDecision: z @@ -43,14 +64,39 @@ const WorktreeCreate = z.object({ .optional(), // Why: mobile clients pass a startup command (e.g. 'claude') so the first // terminal pane launches the selected agent instead of an idle shell. - startupCommand: OptionalString + startupCommand: OptionalString, + createdWithAgent: z + .unknown() + .transform((value) => (isTuiAgent(value) ? value : undefined)) + .optional() }) const WorktreeSet = WorktreeSelector.extend({ displayName: OptionalString, + // Why: empty comments are meaningful metadata updates, so use the plain + // string parser instead of OptionalString's empty-as-undefined behavior. + comment: OptionalPlainString, linkedIssue: TriStateLinkedIssue, - comment: OptionalString, - isPinned: OptionalBoolean + linkedPR: TriStateLinkedIssue, + linkedLinearIssue: z.union([z.string(), z.null()]).optional(), + isArchived: OptionalBoolean, + isUnread: OptionalBoolean, + isPinned: OptionalBoolean, + sortOrder: OptionalFiniteNumber, + lastActivityAt: OptionalFiniteNumber, + createdAt: OptionalFiniteNumber, + sparseDirectories: z.array(z.string()).optional(), + sparseBaseRef: OptionalString, + sparsePresetId: OptionalString, + baseRef: OptionalString, + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), + diffComments: z.array(z.unknown()).optional() }) const WorktreeRemove = WorktreeSelector.extend({ @@ -58,6 +104,19 @@ const WorktreeRemove = WorktreeSelector.extend({ runHooks: OptionalBoolean }) +const WorktreeResolvePrBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + prNumber: z + .unknown() + .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) + .pipe(z.number().int().positive('Missing PR number')), + headRefName: OptionalString, + isCrossRepository: OptionalBoolean +}) + export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.ps', @@ -95,10 +154,15 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: params.name ?? '', baseBranch: params.baseBranch, linkedIssue: params.linkedIssue, + linkedPR: params.linkedPR, comment: params.comment, + displayName: params.displayName, + sparseCheckout: params.sparseCheckout, + pushTarget: params.pushTarget, runHooks: params.runHooks === true, activate: params.activate === true, setupDecision: params.setupDecision, + createdWithAgent: params.createdWithAgent, startup: params.startupCommand ? { command: params.startupCommand } : undefined }) }), @@ -109,11 +173,41 @@ export const WORKTREE_METHODS: RpcMethod[] = [ worktree: await runtime.updateManagedWorktreeMeta(params.worktree, { displayName: params.displayName, linkedIssue: params.linkedIssue, + linkedPR: params.linkedPR, + linkedLinearIssue: params.linkedLinearIssue, comment: params.comment, - isPinned: params.isPinned - }) + isArchived: params.isArchived, + isUnread: params.isUnread, + isPinned: params.isPinned, + sortOrder: params.sortOrder, + lastActivityAt: params.lastActivityAt, + createdAt: params.createdAt, + sparseDirectories: params.sparseDirectories, + sparseBaseRef: params.sparseBaseRef, + sparsePresetId: params.sparsePresetId, + baseRef: params.baseRef, + pushTarget: params.pushTarget, + diffComments: params.diffComments + } as Parameters[1]) }) }), + defineMethod({ + name: 'worktree.persistSortOrder', + params: WorktreeSortOrder, + handler: async (params, { runtime }) => + runtime.persistManagedWorktreeSortOrder(params.orderedIds) + }), + defineMethod({ + name: 'worktree.resolvePrBase', + params: WorktreeResolvePrBase, + handler: async (params, { runtime }) => + runtime.resolveManagedPrBase({ + repoId: params.repo, + prNumber: params.prNumber, + headRefName: params.headRefName, + isCrossRepository: params.isCrossRepository + }) + }), defineMethod({ name: 'worktree.rm', params: WorktreeRemove, diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index 7afa5ae7553..abc9ab20372 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -3,6 +3,8 @@ import { z } from 'zod' import { RpcDispatcher } from './dispatcher' import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { @@ -263,4 +265,57 @@ describe('RpcDispatcher streaming', () => { error: { code: 'runtime_error' } }) }) + + it('ends terminal.subscribe when the backing terminal exits', async () => { + const messages: string[] = [] + let resolveExit!: () => void + const cleanups = new Map void>() + 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().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((resolve) => { + resolveExit = () => + resolve({ + handle: 'terminal-1', + condition: 'exit', + satisfied: true, + status: 'exited', + exitCode: 0 + }) + }) + ) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' } + }), + (msg) => messages.push(msg) + ) + + await vi.waitFor(() => expect(cleanups.has('terminal-1:desktop-1')).toBe(true)) + resolveExit() + await dispatchPromise + + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'end')).toBe(true) + expect(runtime.cleanupSubscription).toHaveBeenCalledWith('terminal-1:desktop-1') + }) }) diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts new file mode 100644 index 00000000000..1f066cca372 --- /dev/null +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -0,0 +1,395 @@ +/* oxlint-disable max-lines -- Why: multiplex transport tests share a live dispatcher harness; splitting it would duplicate stream setup and weaken race coverage. */ +import { 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 type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('terminal multiplex RPC', () => { + it('multiplexes terminal streams and routes desktop resize to the source PTY', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot', + cols: 120, + rows: 40 + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + 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()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + 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 }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-1', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + expect(handlers.has(0)).toBe(true) + + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 5, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 300, rows: 150 } + }) + }) + )! + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + expect(messages.map((msg) => JSON.parse(msg).result)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'fit-override-changed', + streamId: 5, + mode: 'desktop-fit' + }), + expect.objectContaining({ + type: 'driver-changed', + streamId: 5, + driver: { kind: 'idle' } + }) + ]) + ) + expect(runtime.updateDesktopViewport).toHaveBeenCalledWith('pty-1', { + cols: 300, + rows: 150 + }) + expect(handlers.has(5)).toBe(true) + + dataListenerRef.current?.('a') + dataListenerRef.current?.('b') + await vi.runOnlyPendingTimersAsync() + + const outputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + expect(outputFrames).toHaveLength(1) + expect(outputFrames[0]?.streamId).toBe(5) + expect(outputFrames[0] ? decodeTerminalStreamText(outputFrames[0].payload) : '').toBe('ab') + + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 5, + seq: 2, + payload: encodeTerminalStreamText('ls\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'ls\r', + enter: false, + interrupt: false + }) + ) + + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 5, + seq: 3, + payload: encodeTerminalStreamJson({ cols: 100, rows: 30 }) + }) + )! + ) + await vi.waitFor(() => + expect(runtime.updateDesktopViewport).toHaveBeenLastCalledWith('pty-1', { + cols: 100, + rows: 30 + }) + ) + + const snapshotStartFrame = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart) + expect( + snapshotStartFrame && decodeTerminalStreamJson(snapshotStartFrame.payload) + ).toMatchObject({ + cols: 120, + rows: 40 + }) + + runtime.cleanupSubscription('terminal-multiplex:conn-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) + + it('drops desktop multiplex input while a mobile client owns the terminal floor', async () => { + const messages: string[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + 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: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue({ + mode: 'mobile-fit', + cols: 49, + rows: 20 + }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'phone-1' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-locked', + sendBinary: vi.fn(), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 7, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 } + }) + }) + )! + ) + await vi.waitFor(() => expect(handlers.has(7)).toBe(true)) + await vi.waitFor(() => + expect(messages.map((msg) => JSON.parse(msg).result)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'fit-override-changed', + streamId: 7, + mode: 'mobile-fit', + cols: 49, + rows: 20 + }), + expect.objectContaining({ + type: 'driver-changed', + streamId: 7, + driver: { kind: 'mobile', clientId: 'phone-1' } + }) + ]) + ) + ) + + handlers.get(7)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 7, + seq: 2, + payload: encodeTerminalStreamText('typed while locked') + }) + )! + ) + + expect(runtime.sendTerminal).not.toHaveBeenCalled() + cleanups.get('terminal-multiplex:conn-locked')?.() + await dispatchPromise + }) + + it('bounds live output queued while a multiplex snapshot is loading', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + 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()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-buffered', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 } + }) + }) + )! + ) + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + + for (let index = 0; index < 400; index += 1) { + dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`) + } + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + resolveSnapshot({ data: '', cols: 120, rows: 40 }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const output = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(output.length).toBeLessThanOrEqual(256 * 1024) + expect(output).not.toContain('000') + expect(output).toContain('399') + + cleanups.get('terminal-multiplex:conn-buffered')?.() + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts new file mode 100644 index 00000000000..11e2b7476c8 --- /dev/null +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -0,0 +1,249 @@ +import { 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 type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('terminal output batching', () => { + it('coalesces desktop terminal output bursts before emitting stream data', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + 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() + }), + 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(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' } + }), + (msg) => messages.push(msg) + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + const emitData = dataListenerRef.current + if (!emitData) { + throw new Error('missing terminal data listener') + } + emitData('a') + emitData('b') + + expect(messages.map((msg) => JSON.parse(msg).result?.type)).not.toContain('data') + + await vi.runOnlyPendingTimersAsync() + + const dataMessages = messages + .map((msg) => JSON.parse(msg)) + .filter((message) => message.result?.type === 'data') + expect(dataMessages).toHaveLength(1) + expect(dataMessages[0]).toMatchObject({ result: { type: 'data', chunk: 'ab' } }) + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) + + it('streams desktop terminal output as coalesced binary frames when requested', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot', + cols: 80, + rows: 24 + }), + 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) => binaryFrames.push(bytes) + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + const subscribed = messages + .map((msg) => JSON.parse(msg)) + .find((msg) => msg.result?.type === 'subscribed') + expect(subscribed?.result).toMatchObject({ type: 'subscribed', streamId: expect.any(Number) }) + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + + const emitData = dataListenerRef.current + if (!emitData) { + throw new Error('missing terminal data listener') + } + emitData('a') + emitData('b') + + expect(messages.map((msg) => JSON.parse(msg).result?.type)).not.toContain('data') + + await vi.runOnlyPendingTimersAsync() + + const outputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + expect(outputFrames).toHaveLength(1) + expect(outputFrames[0] ? decodeTerminalStreamText(outputFrames[0].payload) : '').toBe('ab') + + 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, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + 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().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + 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 messages: string[] = [] + + 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: vi.fn(), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => { + expect(messages.some((msg) => JSON.parse(msg).result?.streamId)).toBe(true) + }) + const streamId = JSON.parse(messages.find((msg) => JSON.parse(msg).result?.streamId)!).result + .streamId as number + handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 1, + payload: encodeTerminalStreamText('ls\r') + }) + )! + ) + + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'ls\r', + enter: false, + interrupt: false + }) + ) + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts new file mode 100644 index 00000000000..37e4c503135 --- /dev/null +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -0,0 +1,104 @@ +import { 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' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('terminal send RPC', () => { + it('drops desktop input while a mobile client owns the terminal floor', async () => { + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + sendTerminal: vi.fn(), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'x', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ + send: { + handle: 'terminal-1', + accepted: false, + bytesWritten: 0 + } + }) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it('accepts legacy clientless mobile input when the current driver is mobile', async () => { + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 1 + }), + mobileTookFloor: vi.fn().mockResolvedValue(undefined) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'x' + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toMatchObject({ send: { accepted: true, bytesWritten: 1 } }) + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'x', + enter: false, + interrupt: false + }) + expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + }) + + it('routes terminal restore fit through the runtime driver state machine', async () => { + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + reclaimTerminalForDesktop: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.restoreFit', { + terminal: 'terminal-1' + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ restored: true }) + expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1') + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts new file mode 100644 index 00000000000..8c1a0b77c5d --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -0,0 +1,104 @@ +import { 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 type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('terminal subscribe buffering', () => { + it('bounds legacy binary output queued while the initial snapshot is serializing', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + 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-buffered', + sendBinary: (bytes) => binaryFrames.push(bytes) + } + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + for (let index = 0; index < 400; index += 1) { + dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`) + } + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + resolveSnapshot({ data: '', cols: 120, rows: 40 }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const output = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(output.length).toBeLessThanOrEqual(256 * 1024) + expect(output).not.toContain('000') + expect(output).toContain('399') + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 29db2b3b88b..2f3bcf6bdb6 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -23,7 +23,8 @@ describe('WebSocketTransport', () => { }) async function createTransport( - handler?: (msg: string, reply: (response: string) => void) => void + handler?: (msg: string, reply: (response: string) => void) => void, + options: { preAuthTimeoutMs?: number } = {} ) { const tls = makeTls() const transport = new WebSocketTransport({ @@ -32,7 +33,8 @@ describe('WebSocketTransport', () => { // Port 0 lets the OS reserve an available port atomically. port: 0, tlsCert: tls.cert, - tlsKey: tls.key + tlsKey: tls.key, + preAuthTimeoutMs: options.preAuthTimeoutMs }) if (handler) { transport.onMessage(handler) @@ -41,7 +43,8 @@ describe('WebSocketTransport', () => { return { transport, tls } } - function connectWs(port: number): Promise { + function connectWs(target: number | WebSocketTransport): Promise { + const port = typeof target === 'number' ? target : target.resolvedPort return new Promise((resolve, reject) => { const ws = new WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false @@ -75,7 +78,7 @@ describe('WebSocketTransport', () => { await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) const response = await sendAndReceive( ws, JSON.stringify({ id: 'req-1', method: 'test', deviceToken: 'tok' }) @@ -99,9 +102,9 @@ describe('WebSocketTransport', () => { await transport.start() const clients = await Promise.all([ - connectWs(transport.resolvedPort), - connectWs(transport.resolvedPort), - connectWs(transport.resolvedPort) + connectWs(transport), + connectWs(transport), + connectWs(transport) ]) const responses = await Promise.all( @@ -125,7 +128,7 @@ describe('WebSocketTransport', () => { await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) const r1 = sendAndReceive(ws, JSON.stringify({ id: 'a', method: 'first' })) const resp1 = JSON.parse(await r1) @@ -148,7 +151,7 @@ describe('WebSocketTransport', () => { await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) const messages: string[] = [] await new Promise((resolve) => { @@ -173,7 +176,7 @@ describe('WebSocketTransport', () => { await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) // Why: ws maxPayload is 1MB — sending >1MB should trigger close. const oversized = 'x'.repeat(1024 * 1024 + 100) @@ -193,7 +196,7 @@ describe('WebSocketTransport', () => { await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) ws.send(JSON.stringify({ id: 'req-1', method: 'test' })) // Why: wait for the handler to capture the reply function. @@ -215,6 +218,87 @@ describe('WebSocketTransport', () => { expect(() => capturedReply!(JSON.stringify({ id: 'req-1', ok: true }))).not.toThrow() }) + it('runs connection cleanup for sockets that close before auth', async () => { + const { transport } = await createTransport() + const calls: { clientId: string | null; hasOtherConnections: boolean }[] = [] + transport.onConnectionClose((clientId, _ws, hasOtherConnections) => { + calls.push({ clientId, hasOtherConnections }) + }) + + await transport.start() + + const ws = await connectWs(transport) + ws.close() + + const start = Date.now() + while (calls.length === 0 && Date.now() - start < 2_000) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + + expect(calls).toEqual([{ clientId: null, hasOtherConnections: false }]) + }) + + it('terminates every active connection for a revoked client id', async () => { + const { transport } = await createTransport() + const closedClientIds: (string | null)[] = [] + transport.onConnectionClose((clientId) => { + closedClientIds.push(clientId) + }) + + await transport.start() + + const clients = await Promise.all([connectWs(transport), connectWs(transport)]) + const wss = (transport as unknown as { wss: { clients: Set } }).wss + for (const client of wss.clients) { + transport.setClientId(client, 'device-token') + } + + expect(transport.terminateClientConnections('device-token')).toBe(2) + + await Promise.all( + clients.map( + (client) => + new Promise((resolve) => { + if (client.readyState === client.CLOSED) { + resolve() + return + } + client.once('close', () => resolve()) + }) + ) + ) + + const start = Date.now() + while (closedClientIds.length < 2 && Date.now() - start < 2_000) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + + expect(closedClientIds).toEqual(['device-token', 'device-token']) + }) + + it('reaps silent pre-auth sockets so they cannot hold the connection cap', async () => { + const { transport } = await createTransport(undefined, { preAuthTimeoutMs: 50 }) + await transport.start() + + const clients = await Promise.all(Array.from({ length: 32 }, () => connectWs(transport))) + await Promise.all( + clients.map( + (client) => + new Promise((resolve) => { + if (client.readyState === client.CLOSED) { + resolve() + return + } + client.once('close', () => resolve()) + }) + ) + ) + + const liveClient = await connectWs(transport) + expect(liveClient.readyState).toBe(liveClient.OPEN) + liveClient.close() + }) + it('is idempotent on double start', async () => { const { transport } = await createTransport() @@ -282,7 +366,7 @@ describe('WebSocketTransport', () => { // start so we can stamp every accepted ws with a token. await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) // Why: pausing the underlying TCP socket halts both read (ping in) // and write (pong out) at the kernel level, so the `ws` library's // auto-pong can't actually be flushed back. From the server's diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index b6f34c71113..028c3f379ce 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -10,6 +10,7 @@ import type { RpcTransport } from './transport' const MAX_WS_MESSAGE_BYTES = 1024 * 1024 const MAX_WS_CONNECTIONS = 32 +const PRE_AUTH_TIMEOUT_MS = 10_000 type WebSocketMessagePayload = string | Uint8Array type WebSocketMessageHandler = { bivarianceHack( @@ -38,6 +39,8 @@ export type WebSocketTransportOptions = { tlsKey?: string // Why: test-only override. Production uses HEARTBEAT_INTERVAL_MS. heartbeatIntervalMs?: number + // Why: test-only override. Production uses PRE_AUTH_TIMEOUT_MS. + preAuthTimeoutMs?: number } export class WebSocketTransport implements RpcTransport { @@ -46,6 +49,7 @@ export class WebSocketTransport implements RpcTransport { private readonly tlsCert: string | undefined private readonly tlsKey: string | undefined private readonly heartbeatIntervalMs: number + private readonly preAuthTimeoutMs: number private httpServer: HttpsServer | HttpServer | null = null private wss: WebSocketServer | null = null private heartbeatTimer: ReturnType | null = null @@ -55,18 +59,27 @@ export class WebSocketTransport implements RpcTransport { private wsAlive = new WeakSet() private messageHandler: WebSocketMessageHandler | null = null private connectionCloseHandler: - | ((clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void) + | ((clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void) | null = null // Why: maps each WebSocket to the clientId (deviceToken) that authenticated it, // so ws.on('close') can notify the runtime which mobile client disconnected. private wsClientIds = new Map() + private preAuthTimers = new WeakMap>() - constructor({ host, port, tlsCert, tlsKey, heartbeatIntervalMs }: WebSocketTransportOptions) { + constructor({ + host, + port, + tlsCert, + tlsKey, + heartbeatIntervalMs, + preAuthTimeoutMs + }: WebSocketTransportOptions) { this.host = host this.port = port this.tlsCert = tlsCert this.tlsKey = tlsKey this.heartbeatIntervalMs = heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS + this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS } onMessage(handler: WebSocketMessageHandler): void { @@ -80,13 +93,26 @@ export class WebSocketTransport implements RpcTransport { // so client-scoped teardown (mobile-fit overrides, etc.) only fires on the // last disconnect. onConnectionClose( - handler: (clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void + handler: (clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void ): void { this.connectionCloseHandler = handler } setClientId(ws: WebSocket, clientId: string): void { this.wsClientIds.set(ws, clientId) + this.clearPreAuthTimer(ws) + } + + terminateClientConnections(clientId: string): number { + const sockets = Array.from(this.wsClientIds.entries()) + .filter(([, candidateClientId]) => candidateClientId === clientId) + .map(([ws]) => ws) + for (const ws of sockets) { + // Why: revocation is a security boundary; terminate skips the close + // handshake so a revoked mobile stream stops immediately. + ws.terminate() + } + return sockets.length } // Why: when port 0 is passed the OS assigns a random available port. The @@ -235,6 +261,33 @@ export class WebSocketTransport implements RpcTransport { // connection via the RPC `id` field. The transport delegates all auth // and dispatch logic to the message handler set by OrcaRuntimeRpcServer. private handleConnection(ws: WebSocket): void { + let finalized = false + const finalizeConnection = (): void => { + if (finalized) { + return + } + finalized = true + this.clearPreAuthTimer(ws) + const clientId = this.wsClientIds.get(ws) ?? null + this.wsClientIds.delete(ws) + const hasOtherConnections = + clientId !== null && Array.from(this.wsClientIds.values()).includes(clientId) + this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) + } + + const preAuthTimer = setTimeout(() => { + if (!this.wsClientIds.has(ws)) { + // Why: a silent client that only auto-pongs can otherwise occupy one + // of the finite mobile WebSocket slots forever without ever starting + // the E2EE handshake. + ws.terminate() + } + }, this.preAuthTimeoutMs) + if (typeof preAuthTimer.unref === 'function') { + preAuthTimer.unref() + } + this.preAuthTimers.set(ws, preAuthTimer) + // Why: seed alive=true so the first heartbeat tick after connect doesn't // treat a fresh socket as dead. Subsequent pongs (or any inbound traffic) // re-arm it. @@ -272,24 +325,23 @@ export class WebSocketTransport implements RpcTransport { // Why: mobile clients disconnect when the phone locks, loses wifi, or // backgrounds the app. The runtime must clean up connection-scoped state // (e.g., mobile-fit overrides) to prevent orphaned phone-fit on desktop. - ws.on('close', () => { - const clientId = this.wsClientIds.get(ws) - this.wsClientIds.delete(ws) - if (clientId) { - // Why: a paired device may have multiple concurrent sockets open - // (e.g. one per app screen). Per-client teardown must only fire when - // the last socket for this token closes — otherwise closing the - // accounts-screen socket would clobber the host-screen socket's - // state and strand it in a non-functional state until re-paired. - const hasOtherConnections = Array.from(this.wsClientIds.values()).includes(clientId) - this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) - } - }) + ws.on('close', finalizeConnection) ws.on('error', () => { + // Why: close is not guaranteed after every ws error path; finalize here + // too so pre-auth E2EE channels and connection ids cannot leak. + finalizeConnection() ws.close() }) } + + private clearPreAuthTimer(ws: WebSocket): void { + const timer = this.preAuthTimers.get(ws) + if (timer) { + clearTimeout(timer) + this.preAuthTimers.delete(ws) + } + } } function isEAddressInUse(error: unknown): boolean { diff --git a/src/main/runtime/runtime-metadata.test.ts b/src/main/runtime/runtime-metadata.test.ts index 4e93e909dd6..da369b01d40 100644 --- a/src/main/runtime/runtime-metadata.test.ts +++ b/src/main/runtime/runtime-metadata.test.ts @@ -1,8 +1,16 @@ -import { mkdtempSync, statSync } from 'fs' +import { chmodSync, mkdtempSync, readdirSync, statSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it } from 'vitest' import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap' +import { encodePairingOffer } from '../../shared/pairing' +import { + addEnvironmentFromPairingCode, + getEnvironmentStorePath, + listEnvironments +} from '../../shared/runtime-environment-store' +import { DeviceRegistry } from './device-registry' +import { loadOrCreateE2EEKeypair } from './e2ee-keypair' import { clearRuntimeMetadata, clearRuntimeMetadataIfOwned, @@ -161,4 +169,89 @@ describe('runtime metadata', () => { expect(directoryMode).toBe(0o700) } ) + + it.runIf(process.platform !== 'win32')( + 'uses hardened atomic writes for runtime credential stores on Unix', + () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-secure-files-')) + tempDirs.push(userDataPath) + + new DeviceRegistry(userDataPath).addDevice('phone') + loadOrCreateE2EEKeypair(userDataPath) + addEnvironmentFromPairingCode(userDataPath, { + name: 'desk', + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) + }) + + for (const path of [ + join(userDataPath, 'orca-devices.json'), + join(userDataPath, 'orca-e2ee-keypair.json'), + getEnvironmentStorePath(userDataPath) + ]) { + expect(statSync(path).mode & 0o777).toBe(0o600) + } + expect(statSync(userDataPath).mode & 0o777).toBe(0o700) + expect(readdirSync(userDataPath).some((entry) => entry.endsWith('.tmp'))).toBe(false) + } + ) + + it.runIf(process.platform !== 'win32')( + 'hardens existing runtime credential stores before reading them on Unix', + () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-existing-secure-files-')) + tempDirs.push(userDataPath) + const keyMaterial = Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + const pairingCode = encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token', + publicKeyB64: keyMaterial + }) + const environment = addEnvironmentFromPairingCode(userDataPath, { + name: 'desk', + pairingCode + }) + + const devicesPath = join(userDataPath, 'orca-devices.json') + const keypairPath = join(userDataPath, 'orca-e2ee-keypair.json') + const environmentsPath = getEnvironmentStorePath(userDataPath) + writeFileSync( + devicesPath, + JSON.stringify([ + { + deviceId: 'device-1', + name: 'phone', + token: 'token', + pairedAt: 1, + lastSeenAt: 0 + } + ]) + ) + writeFileSync( + keypairPath, + JSON.stringify({ v: 1, publicKeyB64: keyMaterial, secretKeyB64: keyMaterial }) + ) + for (const path of [devicesPath, keypairPath, environmentsPath]) { + chmodSync(path, 0o644) + } + chmodSync(userDataPath, 0o755) + + expect(new DeviceRegistry(userDataPath).getDevice('device-1')).toMatchObject({ + token: 'token', + scope: 'mobile' + }) + expect(loadOrCreateE2EEKeypair(userDataPath).publicKeyB64).toBe(keyMaterial) + expect(listEnvironments(userDataPath)[0]?.id).toBe(environment.id) + + for (const path of [devicesPath, keypairPath, environmentsPath]) { + expect(statSync(path).mode & 0o777).toBe(0o600) + } + expect(statSync(userDataPath).mode & 0o777).toBe(0o700) + } + ) }) diff --git a/src/main/runtime/runtime-metadata.ts b/src/main/runtime/runtime-metadata.ts index 95a7f707c0f..ec46035d7c2 100644 --- a/src/main/runtime/runtime-metadata.ts +++ b/src/main/runtime/runtime-metadata.ts @@ -1,17 +1,6 @@ -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - rmSync, - writeFileSync -} from 'fs' -import { execFileSync } from 'child_process' -import { dirname } from 'path' +import { existsSync, readFileSync, rmSync } from 'fs' import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' - -let cachedWindowsUserSid: string | null | undefined +import { writeSecureJsonFile } from '../../shared/secure-file' export function writeRuntimeMetadata(userDataPath: string, metadata: RuntimeMetadata): void { const metadataPath = getRuntimeMetadataPath(userDataPath) @@ -63,85 +52,5 @@ export function clearRuntimeMetadataIfOwned( } function writeMetadataFile(path: string, metadata: RuntimeMetadata): void { - const dir = dirname(path) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }) - } - hardenRuntimePath(dir, { isDirectory: true, platform: process.platform }) - const tmpFile = `${path}.tmp` - writeFileSync(tmpFile, JSON.stringify(metadata, null, 2), { - encoding: 'utf-8', - mode: 0o600 - }) - hardenRuntimePath(tmpFile, { isDirectory: false, platform: process.platform }) - renameSync(tmpFile, path) - // Why: runtime bootstrap files carry auth material that lets the local CLI - // attach to a live Orca runtime. The published file must stay scoped to - // the current user. - hardenRuntimePath(path, { isDirectory: false, platform: process.platform }) -} - -function hardenRuntimePath( - targetPath: string, - options: { - isDirectory: boolean - platform: NodeJS.Platform - } -): void { - if (options.platform === 'win32') { - bestEffortRestrictWindowsPath(targetPath) - return - } - chmodSync(targetPath, options.isDirectory ? 0o700 : 0o600) -} - -function bestEffortRestrictWindowsPath(targetPath: string): void { - const currentUserSid = getCurrentWindowsUserSid() - if (!currentUserSid) { - return - } - try { - execFileSync( - 'icacls', - [ - targetPath, - '/inheritance:r', - '/grant:r', - `*${currentUserSid}:(F)`, - '*S-1-5-18:(F)', - '*S-1-5-32-544:(F)' - ], - { - stdio: 'ignore', - windowsHide: true, - timeout: 5000 - } - ) - } catch { - // Why: runtime metadata hardening should not prevent Orca from starting on - // Windows machines where icacls is unavailable or locked down differently. - } -} - -function getCurrentWindowsUserSid(): string | null { - if (cachedWindowsUserSid !== undefined) { - return cachedWindowsUserSid - } - try { - const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - windowsHide: true, - timeout: 5000 - }).trim() - const columns = parseCsvLine(output) - cachedWindowsUserSid = columns[1] ?? null - } catch { - cachedWindowsUserSid = null - } - return cachedWindowsUserSid -} - -function parseCsvLine(line: string): string[] { - return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, '')) + writeSecureJsonFile(path, metadata) } diff --git a/src/main/runtime/runtime-relative-paths.ts b/src/main/runtime/runtime-relative-paths.ts new file mode 100644 index 00000000000..c74d965c0c9 --- /dev/null +++ b/src/main/runtime/runtime-relative-paths.ts @@ -0,0 +1,29 @@ +import { posix, win32 } from 'path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +export function joinWorktreeRelativePath(rootPath: string, relativePath: string): string { + const normalizedRelativePath = relativePath.replace(/\\/g, '/') + if (isWindowsAbsolutePathLike(rootPath)) { + return win32.join(rootPath.replace(/\//g, '\\'), ...normalizedRelativePath.split('/')) + } + return posix.join(rootPath, ...normalizedRelativePath.split('/')) +} + +export function normalizeRuntimeRelativePath(relativePath: string): string { + const normalized = relativePath.replace(/\\/g, '/').replace(/\/+$/, '') + if (normalized === '') { + return '' + } + if (!isSafeRuntimeRelativePath(normalized)) { + throw new Error('invalid_relative_path') + } + return normalized +} + +function isSafeRuntimeRelativePath(relativePath: string): boolean { + if (relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) { + return false + } + const parts = relativePath.split('/') + return parts.every((part) => part !== '' && part !== '.' && part !== '..') +} diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index d07fc93b01b..acbbcdaa979 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -3,13 +3,18 @@ import { existsSync, mkdtempSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { createConnection, type Socket } from 'net' +import { EventEmitter } from 'events' import { describe, expect, it, vi } from 'vitest' import Database from 'better-sqlite3' +import WebSocket from 'ws' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' import * as runtimeMetadataModule from './runtime-metadata' import { readRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' +import { parsePairingCode } from '../../shared/pairing' +import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' +import { DeviceRegistry } from './device-registry' vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ @@ -104,6 +109,73 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('timed out waiting for condition') + } + await sleep(20) + } +} + +function connectWs(endpoint: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(endpoint) + ws.once('open', () => resolve(ws)) + ws.once('error', reject) + }) +} + +function nextWsMessage(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.once('message', (data) => { + resolve(typeof data === 'string' ? data : data.toString('utf-8')) + }) + }) +} + +function waitForWsClose(ws: WebSocket): Promise { + return new Promise((resolve) => { + if (ws.readyState === ws.CLOSED) { + resolve() + return + } + ws.once('close', () => resolve()) + }) +} + +async function authenticateMobileWs(pairingUrl: string): Promise { + const parsed = parsePairingCode(pairingUrl) + expect(parsed).toBeTruthy() + const ws = await connectWs(parsed!.endpoint) + const mobileKeys = generateKeyPair() + const serverPublicKey = Uint8Array.from(Buffer.from(parsed!.publicKeyB64, 'base64')) + const sharedKey = deriveSharedKey(mobileKeys.secretKey, serverPublicKey) + + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(mobileKeys.publicKey).toString('base64') + }) + ) + expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' }) + + ws.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: parsed!.deviceToken }), sharedKey) + ) + expect(JSON.parse(decrypt(await nextWsMessage(ws), sharedKey)!)).toEqual({ + type: 'e2ee_authenticated' + }) + + return ws +} + +class FakeWebSocket extends EventEmitter { + readonly OPEN = 1 + readyState = this.OPEN +} + describe('OrcaRuntimeRpcServer', () => { const makeStore = (overrides?: { isUnread?: boolean }) => ({ getRepo: (id: string) => @@ -176,6 +248,418 @@ describe('OrcaRuntimeRpcServer', () => { }) }) + it('creates a pairing offer for the active WebSocket transport', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + const offer = server.createPairingOffer({ address: '100.64.1.20', name: 'CLI test' }) + expect(offer.available).toBe(true) + if (offer.available) { + expect(offer.endpoint).toContain('100.64.1.20') + const parsed = parsePairingCode(offer.pairingUrl) + expect(parsed?.endpoint).toBe(offer.endpoint) + expect(parsed?.deviceToken).toBeTruthy() + expect(parsed?.publicKeyB64).toBeTruthy() + expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.scope).toBe('runtime') + } + + await server.stop() + }) + + it('formats pairing-address overrides for IPv6 and host-port tunnel endpoints', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + try { + const ipv6 = server.createPairingOffer({ address: '::1', name: 'IPv6 test' }) + expect(ipv6.available).toBe(true) + if (ipv6.available) { + expect(ipv6.endpoint).toMatch(/^ws:\/\/\[::1\]:\d+$/) + expect(parsePairingCode(ipv6.pairingUrl)?.endpoint).toBe(ipv6.endpoint) + } + + const tunnel = server.createPairingOffer({ + address: 'tunnel.example.com:443', + name: 'Tunnel test' + }) + expect(tunnel.available).toBe(true) + if (tunnel.available) { + expect(tunnel.endpoint).toBe('ws://tunnel.example.com:443') + } + + const fullUrl = server.createPairingOffer({ + address: 'wss://runtime.example.com/orca', + name: 'Full URL test' + }) + expect(fullUrl.available).toBe(true) + if (fullUrl.available) { + expect(fullUrl.endpoint).toBe('wss://runtime.example.com/orca') + } + } finally { + await server.stop() + } + }) + + it('creates mobile-scoped pairing offers for headless mobile pairing', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + try { + const offer = server.createPairingOffer({ + address: '100.64.1.20', + name: 'Mobile test', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + + expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.scope).toBe('mobile') + const parsed = parsePairingCode(offer.pairingUrl) + expect(parsed?.endpoint).toBe(offer.endpoint) + expect(parsed?.endpoint).toContain('100.64.1.20') + } finally { + await server.stop() + } + }) + + it('cleans up pre-auth E2EE WebSocket state when the socket closes', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + try { + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'mobile-test', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + const parsed = parsePairingCode(offer.pairingUrl)! + const ws = await connectWs(parsed.endpoint) + const mobileKeys = generateKeyPair() + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(mobileKeys.publicKey).toString('base64') + }) + ) + expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' }) + expect(server['e2eeChannels'].size).toBe(1) + expect(server['wsConnectionIds'].size).toBe(1) + + ws.close() + await waitForWsClose(ws) + await waitFor(() => server['e2eeChannels'].size === 0 && server['wsConnectionIds'].size === 0) + } finally { + await server.stop() + } + }) + + it('terminates active WebSockets for a revoked mobile device', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const disconnectSpy = vi.spyOn(runtime, 'onClientDisconnected') + + await server.start() + + try { + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'mobile-test', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + const first = await authenticateMobileWs(offer.pairingUrl) + const second = await authenticateMobileWs(offer.pairingUrl) + + expect(server.revokeMobileDevice(offer.deviceId)).toBe(true) + await Promise.all([waitForWsClose(first), waitForWsClose(second)]) + await waitFor(() => server['e2eeChannels'].size === 0 && server['wsConnectionIds'].size === 0) + + expect(disconnectSpy).toHaveBeenCalledTimes(1) + } finally { + disconnectSpy.mockRestore() + await server.stop() + } + }) + + it('does not revoke runtime-scoped devices through mobile revocation', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + try { + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'runtime-test', + scope: 'runtime' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + + expect(server.revokeMobileDevice(offer.deviceId)).toBe(false) + expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.scope).toBe('runtime') + } finally { + await server.stop() + } + }) + + it('caps WebSocket long-polls and aborts them when the socket closes', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: false, + longPollCap: 1 + }) + const device = server['deviceRegistry'] ?? null + expect(device).toBeNull() + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') + const ws = new FakeWebSocket() + server['wsConnectionIds'].set(ws as unknown as WebSocket, 'conn-test') + const replies: Record[] = [] + + try { + const first = server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_wait', + method: 'orchestration.check', + deviceToken: entry.token, + params: { terminal: 'term_wait', wait: true, timeoutMs: 10_000 } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {}, + undefined, + ws as unknown as WebSocket + ) + + await waitFor(() => server['activeLongPolls'] === 1) + + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_busy', + method: 'orchestration.check', + deviceToken: entry.token, + params: { terminal: 'term_busy', wait: true, timeoutMs: 10_000 } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {}, + undefined, + ws as unknown as WebSocket + ) + + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_busy', + ok: false, + error: expect.objectContaining({ code: 'runtime_busy' }) + }) + ) + expect(server['activeLongPolls']).toBe(1) + + ws.readyState = 3 + ws.emit('close') + await first + + expect(server['activeLongPolls']).toBe(0) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_wait', ok: true })) + } finally { + db.close() + await server.stop() + } + }) + + it('limits mobile-scoped WebSocket tokens to the mobile RPC surface', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true }) + const selectClaudeAccount = vi.fn().mockResolvedValue({ ok: true }) + const selectCodexAccount = vi.fn().mockResolvedValue({ ok: true }) + const removeClaudeAccount = vi.fn().mockResolvedValue({ ok: true }) + const readTerminal = vi.fn().mockResolvedValue({ tail: ['ok'] }) + const runtime = { + getRuntimeId: () => 'test-runtime', + getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }), + pushRuntimeGit, + selectClaudeAccount, + selectCodexAccount, + removeClaudeAccount, + readTerminal + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const mobile = server['deviceRegistry']!.addDevice('phone', 'mobile') + const replies: Record[] = [] + + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_forbidden', + method: 'git.push', + deviceToken: mobile.token, + params: { worktree: 'id:wt-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_allowed', + method: 'status.get', + deviceToken: mobile.token + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_select_claude', + method: 'accounts.selectClaude', + deviceToken: mobile.token, + params: { accountId: 'claude-account' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_select_codex', + method: 'accounts.selectCodex', + deviceToken: mobile.token, + params: { accountId: null } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_remove_claude', + method: 'accounts.removeClaude', + deviceToken: mobile.token, + params: { accountId: 'claude-account' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_terminal_read', + method: 'terminal.read', + deviceToken: mobile.token, + params: { terminal: 'term-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_forbidden', + ok: false, + error: expect.objectContaining({ code: 'forbidden' }) + }) + ) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_allowed', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_claude', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_codex', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_terminal_read', ok: true })) + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_remove_claude', + ok: false, + error: expect.objectContaining({ code: 'forbidden' }) + }) + ) + expect(selectClaudeAccount).toHaveBeenCalledWith('claude-account') + expect(selectCodexAccount).toHaveBeenCalledWith(null) + expect(readTerminal).toHaveBeenCalledWith('term-1', { cursor: undefined }) + expect(removeClaudeAccount).not.toHaveBeenCalled() + expect(pushRuntimeGit).not.toHaveBeenCalled() + }) + + it('allows runtime-scoped WebSocket tokens to use the full RPC surface', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + pushRuntimeGit + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const runtimeDevice = server['deviceRegistry']!.addDevice('cli', 'runtime') + const replies: Record[] = [] + + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_push', + method: 'git.push', + deviceToken: runtimeDevice.token, + params: { worktree: 'id:wt-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_push', ok: true })) + expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', undefined, undefined) + }) + it('leaves the last published metadata in place when a runtime stops', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 19f80642b0d..da555f98833 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -17,9 +17,14 @@ import type { RpcMessageContext, RpcTransport } from './rpc/transport' import { UnixSocketTransport } from './rpc/unix-socket-transport' import { WebSocketTransport } from './rpc/ws-transport' import type { WebSocket } from 'ws' -import { DeviceRegistry } from './device-registry' +import { DeviceRegistry, type DeviceScope } from './device-registry' import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' import { E2EEChannel } from './rpc/e2ee-channel' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' +import { + decodeTerminalStreamFrame, + type TerminalStreamFrame +} from '../../shared/terminal-stream-protocol' const DEFAULT_WS_PORT = 6768 @@ -56,6 +61,89 @@ const KEEPALIVE_INTERVAL_MS = 10_000 // queuing. See design doc §3.1 + §7 risk #2. const LONG_POLL_CAP = 16 +function resolvePairingEndpoint(rawEndpoint: string, address: string | null | undefined): string { + const endpoint = new URL(rawEndpoint) + const override = address?.trim() + if (!override) { + endpoint.hostname = '127.0.0.1' + return formatWebSocketUrl(endpoint) + } + if (/^wss?:\/\//i.test(override)) { + return formatWebSocketUrl(new URL(override)) + } + const parsed = parsePairingAddressOverride(override) + endpoint.hostname = parsed.host.includes(':') + ? `[${parsed.host.replace(/^\[|\]$/g, '')}]` + : parsed.host + if (parsed.port) { + endpoint.port = parsed.port + } + return formatWebSocketUrl(endpoint) +} + +function parsePairingAddressOverride(address: string): { host: string; port: string | null } { + if (address.startsWith('[') || address.split(':').length === 2) { + try { + const parsed = new URL(`ws://${address}`) + return { host: parsed.hostname.replace(/^\[|\]$/g, ''), port: parsed.port || null } + } catch { + return { host: address, port: null } + } + } + return { host: address, port: null } +} + +function formatWebSocketUrl(url: URL): string { + const formatted = url.toString() + return url.pathname === '/' && !url.search && !url.hash ? formatted.replace(/\/$/, '') : formatted +} + +const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ + 'accounts.list', + 'accounts.selectClaude', + 'accounts.selectCodex', + 'accounts.subscribe', + 'accounts.unsubscribe', + 'files.list', + 'files.open', + 'files.read', + 'markdown.readTab', + 'markdown.saveTab', + 'notifications.subscribe', + 'notifications.unsubscribe', + 'repo.hooks', + 'repo.list', + 'session.tabs.activate', + 'session.tabs.close', + 'session.tabs.createTerminal', + 'session.tabs.list', + 'session.tabs.subscribe', + 'session.tabs.unsubscribe', + 'stats.summary', + 'status.get', + 'terminal.clearBuffer', + 'terminal.close', + 'terminal.create', + 'terminal.focus', + 'terminal.getAutoRestoreFit', + 'terminal.list', + 'terminal.multiplex', + 'terminal.read', + 'terminal.rename', + 'terminal.send', + 'terminal.setAutoRestoreFit', + 'terminal.setDisplayMode', + 'terminal.subscribe', + 'terminal.unsubscribe', + 'terminal.updateViewport', + 'worktree.activate', + 'worktree.create', + 'worktree.ps', + 'worktree.rm', + 'worktree.set', + 'worktree.sleep' +]) + // Why: a long-poll request is one whose handler blocks for an unbounded // amount of time waiting for an external event (today, only // `orchestration.check` with `wait === true`). This function is the single @@ -83,6 +171,7 @@ export class OrcaRuntimeRpcServer { private deviceRegistry: DeviceRegistry | null = null private e2eeKeypair: E2EEKeypair | null = null private tlsFingerprint: string | null = null + private wsTransport: WebSocketTransport | null = null private activeTransports: RpcTransport[] = [] private transports: RuntimeTransportMetadata[] = [] // Why: each WebSocket connection has its own E2EE channel that manages the @@ -92,6 +181,10 @@ export class OrcaRuntimeRpcServer { // subscriptions, so the server can reap a closing socket's subscriptions // without affecting other live sockets that share the same deviceToken. private wsConnectionIds = new Map() + private readonly binaryStreamHandlers = new Map< + string, + Map void> + >() // Why: separate from Node's server.maxConnections because we need to count // only long-running dispatches, not every in-flight short RPC. See §3.1 + // §7 risk #2. @@ -134,11 +227,87 @@ export class OrcaRuntimeRpcServer { return this.e2eeKeypair } + revokeMobileDevice(deviceId: string): boolean { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'mobile' || !this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.wsTransport?.terminateClientConnections(device.token) + return true + } + getWebSocketEndpoint(): string | null { const ws = this.transports.find((t) => t.kind === 'websocket') return ws?.endpoint ?? null } + createPairingOffer(args: { + address?: string | null + name?: string + rotate?: boolean + scope?: DeviceScope + }): + | { available: false } + | { available: true; pairingUrl: string; endpoint: string; deviceId: string } { + const rawEndpoint = this.getWebSocketEndpoint() + const publicKeyB64 = this.getE2EEPublicKey() + if (!rawEndpoint || !this.deviceRegistry || !publicKeyB64) { + return { available: false } + } + + const endpoint = resolvePairingEndpoint(rawEndpoint, args.address) + const deviceName = args.name ?? `CLI ${new Date().toLocaleDateString()}` + const scope = args.scope ?? 'runtime' + const device = args.rotate + ? this.deviceRegistry.rotatePendingDevice(deviceName, scope) + : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope) + const pairingUrl = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: device.token, + publicKeyB64 + }) + return { available: true, pairingUrl, endpoint, deviceId: device.deviceId } + } + + private registerBinaryStreamHandler( + connectionId: string | undefined, + streamId: number, + handler: (frame: TerminalStreamFrame) => void + ): () => void { + if (!connectionId || !Number.isInteger(streamId) || streamId < 0) { + return () => {} + } + let handlers = this.binaryStreamHandlers.get(connectionId) + if (!handlers) { + handlers = new Map() + this.binaryStreamHandlers.set(connectionId, handlers) + } + handlers.set(streamId, handler) + return () => { + const current = this.binaryStreamHandlers.get(connectionId) + if (!current || current.get(streamId) !== handler) { + return + } + current.delete(streamId) + if (current.size === 0) { + this.binaryStreamHandlers.delete(connectionId) + } + } + } + + private handleWebSocketBinaryMessage(bytes: Uint8Array, ws: WebSocket): void { + const connectionId = this.wsConnectionIds.get(ws) + if (!connectionId) { + return + } + const frame = decodeTerminalStreamFrame(bytes) + if (!frame) { + return + } + this.binaryStreamHandlers.get(connectionId)?.get(frame.streamId)?.(frame) + } + async start(): Promise { if (this.activeTransports.length > 0) { return @@ -217,6 +386,7 @@ export class OrcaRuntimeRpcServer { host: '0.0.0.0', port: this.wsPort }) + this.wsTransport = wsTransport // Why: each WebSocket connection gets an E2EE channel that handles the // handshake before any RPC messages are processed. The channel decrypts @@ -259,6 +429,7 @@ export class OrcaRuntimeRpcServer { ws ) }) + channel.onBinaryMessage((bytes) => this.handleWebSocketBinaryMessage(bytes, ws)) this.e2eeChannels.set(ws, channel) } channel.handleRawMessage(msg) @@ -279,6 +450,7 @@ export class OrcaRuntimeRpcServer { if (connectionId) { this.runtime.cleanupSubscriptionsForConnection(connectionId) this.runtime.cancelMobileDictationForConnection(connectionId) + this.binaryStreamHandlers.delete(connectionId) this.wsConnectionIds.delete(ws) } const channel = this.e2eeChannels.get(ws) @@ -286,7 +458,7 @@ export class OrcaRuntimeRpcServer { channel.destroy() this.e2eeChannels.delete(ws) } - if (!hasOtherConnections) { + if (clientId && !hasOtherConnections) { this.runtime.onClientDisconnected(clientId) } }) @@ -302,6 +474,7 @@ export class OrcaRuntimeRpcServer { // function if it fails to start (e.g., port in use). Log and continue // with Unix socket only. console.error('[runtime] Failed to start WebSocket transport:', error) + this.wsTransport = null } } @@ -328,6 +501,7 @@ export class OrcaRuntimeRpcServer { const transports = this.activeTransports this.activeTransports = [] this.transports = [] + this.wsTransport = null if (transports.length === 0) { return } @@ -447,10 +621,23 @@ export class OrcaRuntimeRpcServer { reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token'))) return } - if (!this.deviceRegistry?.validateToken(token)) { + const device = this.deviceRegistry?.validateToken(token) + if (!device) { reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token'))) return } + if (device.scope === 'mobile' && !MOBILE_RPC_METHOD_ALLOWLIST.has(request.method)) { + reply( + JSON.stringify( + this.buildError( + request.id, + 'forbidden', + `Method '${request.method}' is not available to mobile clients` + ) + ) + ) + return + } // Why: associate the deviceToken with this WebSocket so ws.on('close') // can notify the runtime which mobile client disconnected. @@ -458,12 +645,54 @@ export class OrcaRuntimeRpcServer { wsTransport.setClientId(ws, token) } + const longPoll = isLongPollRequest(request) + if (longPoll && this.activeLongPolls >= this.longPollCap) { + reply( + JSON.stringify( + this.buildError( + request.id, + 'runtime_busy', + 'long-poll capacity reached; retry with backoff' + ) + ) + ) + return + } + + let abortController: AbortController | null = null + let abortOnClose: (() => void) | null = null + if (longPoll) { + this.activeLongPolls += 1 + abortController = new AbortController() + if (ws) { + abortOnClose = () => abortController?.abort() + ws.once('close', abortOnClose) + ws.once('error', abortOnClose) + if (ws.readyState !== ws.OPEN) { + abortController.abort() + } + } + } + const connectionId = ws ? this.wsConnectionIds.get(ws) : undefined - await this.dispatcher.dispatchStreaming(request, reply, { - connectionId, - clientId: token, - sendBinary - }) + try { + await this.dispatcher.dispatchStreaming(request, reply, { + connectionId, + clientId: token, + signal: abortController?.signal, + sendBinary, + registerBinaryStreamHandler: (streamId, handler) => + this.registerBinaryStreamHandler(connectionId, streamId, handler) + }) + } finally { + if (abortOnClose && ws) { + ws.off('close', abortOnClose) + ws.off('error', abortOnClose) + } + if (longPoll) { + this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) + } + } } private buildError(id: string, code: string, message: string): RpcResponse { diff --git a/src/main/ssh/sftp-upload.test.ts b/src/main/ssh/sftp-upload.test.ts new file mode 100644 index 00000000000..40676a34dc9 --- /dev/null +++ b/src/main/ssh/sftp-upload.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, mkdir, realpath, symlink, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { Writable } from 'stream' +import { describe, expect, it, vi } from 'vitest' +import type { SFTPWrapper } from 'ssh2' +import { uploadBuffer, uploadDirectory, uploadFile } from './sftp-upload' + +function createWritable(): Writable { + return new Writable({ + write(_chunk, _encoding, callback) { + callback() + } + }) +} + +function createSftpMock(): SFTPWrapper { + return { + mkdir: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)), + createWriteStream: vi.fn(() => createWritable()) + } as unknown as SFTPWrapper +} + +describe('sftp-upload', () => { + it('can create the first binary upload chunk without clobbering an existing temp file', async () => { + const sftp = createSftpMock() + + await uploadBuffer(sftp, Buffer.from('png'), '/remote/.logo.orca-upload', { + exclusive: true + }) + + expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/.logo.orca-upload', { + flags: 'wx' + }) + }) + + it('uses no-clobber writes for nested files during exclusive directory upload', async () => { + const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) + await mkdir(join(localDir, 'nested')) + await writeFile(join(localDir, 'nested', 'asset.txt'), 'asset') + const sftp = createSftpMock() + + await uploadDirectory(sftp, localDir, '/remote/assets', await realpath(localDir), { + exclusive: true + }) + + expect(sftp.mkdir).toHaveBeenCalledWith('/remote/assets/nested', expect.any(Function)) + expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/assets/nested/asset.txt', { + flags: 'wx' + }) + }) + + it('does not create the remote file when the local source is a symlink', async () => { + const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) + await writeFile(join(localDir, 'target.txt'), 'secret') + await symlink(join(localDir, 'target.txt'), join(localDir, 'link.txt')) + const sftp = createSftpMock() + + await expect(uploadFile(sftp, join(localDir, 'link.txt'), '/remote/link.txt')).rejects.toThrow() + + expect(sftp.createWriteStream).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts index b7491542b64..b7dd3389590 100644 --- a/src/main/ssh/sftp-upload.ts +++ b/src/main/ssh/sftp-upload.ts @@ -1,16 +1,21 @@ -import { createReadStream } from 'fs' -import { readdir } from 'fs/promises' -import { join as pathJoin } from 'path' +import { constants } from 'fs' +import type { ReadStream } from 'fs' +import { lstat, open, readdir, realpath } from 'fs/promises' +import { isAbsolute, join as pathJoin, relative } from 'path' import type { SFTPWrapper } from 'ssh2' -export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise { +export function mkdirSftp( + sftp: SFTPWrapper, + path: string, + options?: { allowExisting?: boolean } +): Promise { return new Promise((resolve, reject) => { sftp.mkdir(path, (err) => { // Why: SFTP status code 4 (SSH_FX_FAILURE) is a generic code that // OpenSSH returns for "already exists," but could also cover other // failures (e.g. permission denied on parent). We accept this ambiguity // because the next operation (write/recurse) will surface the real error. - if (err && (err as { code?: number }).code !== 4) { + if (err && ((err as { code?: number }).code !== 4 || options?.allowExisting === false)) { reject(err) } else { resolve() @@ -22,58 +27,131 @@ export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise { export function uploadFile( sftp: SFTPWrapper, localPath: string, - remotePath: string + remotePath: string, + options?: { exclusive?: boolean } ): Promise { return new Promise((resolve, reject) => { let settled = false - const readStream = createReadStream(localPath) - const writeStream = sftp.createWriteStream(remotePath) + let readStream: ReadStream | null = null + let fileHandle: Awaited> | null = null + let writeStream: ReturnType | null = null + + const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { + if (settled) { + return + } + settled = true + readStream?.destroy() + writeStream?.destroy() + void fileHandle?.close().catch(() => {}) + fn(val as never) + } + + void open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + .then(async (handle) => { + if (settled) { + void handle.close().catch(() => {}) + return + } + fileHandle = handle + const statResult = await lstat(localPath) + if (statResult.isSymbolicLink() || !statResult.isFile()) { + throw new Error(`Unsupported upload source: ${localPath}`) + } + const openedStat = await handle.stat() + if ( + !openedStat.isFile() || + openedStat.size !== statResult.size || + (statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) || + (statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev) + ) { + throw new Error(`File changed during upload: ${localPath}`) + } + // Why: validate the local source before creating the remote write + // target, so rejected sources do not leave empty files behind. + writeStream = sftp.createWriteStream(remotePath, { + flags: options?.exclusive ? 'wx' : 'w' + }) + writeStream.on('close', () => settle(resolve)) + writeStream.on('error', (err) => settle(reject, err)) + readStream = handle.createReadStream() + readStream.on('error', (err) => settle(reject, err)) + readStream.pipe(writeStream) + }) + .catch((err: unknown) => settle(reject, err)) + }) +} + +export function uploadBuffer( + sftp: SFTPWrapper, + buffer: Buffer, + remotePath: string, + options?: { append?: boolean; exclusive?: boolean } +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const writeStream = sftp.createWriteStream(remotePath, { + flags: options?.append ? 'a' : options?.exclusive ? 'wx' : 'w' + }) const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { if (settled) { return } settled = true - readStream.destroy() writeStream.destroy() fn(val as never) } writeStream.on('close', () => settle(resolve)) writeStream.on('error', (err) => settle(reject, err)) - readStream.on('error', (err) => settle(reject, err)) - - readStream.pipe(writeStream) + writeStream.end(buffer) }) } export async function uploadDirectory( sftp: SFTPWrapper, localDir: string, - remoteDir: string + remoteDir: string, + rootRealPath = localDir, + options?: { exclusive?: boolean } ): Promise { + await assertLocalUploadPathInsideRoot(rootRealPath, localDir) const entries = await readdir(localDir, { withFileTypes: true }) for (const entry of entries) { const localPath = pathJoin(localDir, entry.name) const remotePath = `${remoteDir}/${entry.name}` + await assertLocalUploadPathInsideRoot(rootRealPath, localPath) + const statResult = await lstat(localPath) // Why: skip symlinks and special files (sockets, FIFOs, devices) to // prevent following symlinks that could exfiltrate local files to the // remote. The caller's pre-scan catches symlinks up-front, but this // guard closes the TOCTOU gap if one is created between scan and upload. - if (entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory())) { + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { continue } - if (entry.isDirectory()) { - await mkdirSftp(sftp, remotePath) - await uploadDirectory(sftp, localPath, remotePath) + if (statResult.isDirectory()) { + await mkdirSftp(sftp, remotePath, { allowExisting: !options?.exclusive }) + await uploadDirectory(sftp, localPath, remotePath, rootRealPath, options) } else { - await uploadFile(sftp, localPath, remotePath) + await uploadFile(sftp, localPath, remotePath, { exclusive: options?.exclusive }) } } } +async function assertLocalUploadPathInsideRoot( + rootRealPath: string, + candidatePath: string +): Promise { + const candidateRealPath = await realpath(candidatePath) + const relativeToRoot = relative(rootRealPath, candidateRealPath) + if (relativeToRoot !== '' && (relativeToRoot.startsWith('..') || isAbsolute(relativeToRoot))) { + throw new Error(`Path escaped upload root: ${candidatePath}`) + } +} + /** * Check whether a path exists on the remote via SFTP lstat. * Returns true if the path exists (file, directory, or symlink). diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index d75807f86ce..cbccd4bb4ce 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -74,6 +74,7 @@ export class SshRelaySession { | null = null private _onReady: ((targetId: string) => void) | null = null private portScanner: PortScanner | null = null + private currentConnection: SshConnection | null = null constructor( readonly targetId: string, @@ -113,6 +114,13 @@ export class SshRelaySession { return (this._state as RelaySessionState) === 'disposed' } + private requireReadyConnection(): SshConnection { + if (!this.currentConnection) { + throw new Error('SSH connection is not active') + } + return this.currentConnection + } + getMux(): SshChannelMultiplexer | null { return this.mux } @@ -129,6 +137,7 @@ export class SshRelaySession { throw new Error(`Cannot establish relay session in state: ${this._state}`) } this._state = 'deploying' + this.currentConnection = conn try { const { transport } = await deployAndLaunchRelay( @@ -239,6 +248,7 @@ export class SshRelaySession { this.abortController = abortController this._state = 'reconnecting' + this.currentConnection = conn // Why: stop scanning before teardownProviders so the polling timer doesn't // fire against a disposed multiplexer. @@ -371,6 +381,7 @@ export class SshRelaySession { this.broadcastEmptyLists() this.teardownProviders('shutdown') this.store.markSshRemotePtyLeases(this.targetId, 'terminated') + this.currentConnection = null this._state = 'disposed' } @@ -386,6 +397,7 @@ export class SshRelaySession { // clearing PTY ownership needed for reattach. this.teardownProviders('connection_lost') this.store.markSshRemotePtyLeases(this.targetId, 'detached') + this.currentConnection = null this._state = 'disposed' } @@ -427,7 +439,9 @@ export class SshRelaySession { const ptyProvider = new SshPtyProvider(this.targetId, mux) registerSshPtyProvider(this.targetId, ptyProvider) - const fsProvider = new SshFilesystemProvider(this.targetId, mux) + const fsProvider = new SshFilesystemProvider(this.targetId, mux, () => + this.requireReadyConnection().sftp() + ) registerSshFilesystemProvider(this.targetId, fsProvider) const gitProvider = new SshGitProvider(this.targetId, mux) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f46f7e977ce..533879c1fc7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -85,6 +85,8 @@ import type { WorktreeStartupLaunch, WorkspaceSessionState } from '../shared/types' +import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' +import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' import type { AddIssueCommentBySlugArgs, ClearProjectItemFieldArgs, @@ -139,6 +141,11 @@ import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { AgentStatusIpcPayload } from '../shared/agent-status-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' + +type RuntimeEnvironmentSubscriptionHandle = { + unsubscribe: () => void + sendBinary: (bytes: Uint8Array) => void +} import type { RuntimeMobileMarkdownRequest, RuntimeMobileMarkdownResponse @@ -1061,6 +1068,11 @@ export type PreloadApi = { createFile: (args: { filePath: string; connectionId?: string }) => Promise createDir: (args: { dirPath: string; connectionId?: string }) => Promise rename: (args: { oldPath: string; newPath: string; connectionId?: string }) => Promise + copy: (args: { + sourcePath: string + destinationPath: string + connectionId?: string + }) => Promise deletePath: (args: { targetPath: string connectionId?: string @@ -1081,6 +1093,7 @@ export type PreloadApi = { sourcePaths: string[] destDir: string connectionId?: string + ensureDir?: boolean }) => Promise<{ results: ( | { @@ -1102,6 +1115,30 @@ export type PreloadApi = { } )[] }> + stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] }) => Promise<{ + sources: ( + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: ( + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + )[] + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> resolveDroppedPathsForAgent: (args: { paths: string[] worktreePath: string @@ -1357,6 +1394,7 @@ export type PreloadApi = { runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise getStatus: () => Promise + call: (args: { method: string; params?: unknown }) => Promise> getTerminalFitOverrides: () => Promise< { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] > @@ -1376,6 +1414,39 @@ export type PreloadApi = { }) => void ) => () => void } + runtimeEnvironments: { + list: () => Promise + addFromPairingCode: (args: { + name: string + pairingCode: string + }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> + resolve: (args: { selector: string }) => Promise + remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> + getStatus: (args: { + selector: string + timeoutMs?: number + }) => Promise> + call: (args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + }) => Promise> + subscribe: ( + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + }, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } + ) => Promise + } rateLimits: { get: () => Promise refresh: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index c744b23d047..b9b5d34d123 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,6 +33,8 @@ import type { WorktreeRemoteBranchConflictEvent } from '../shared/types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' +import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' +import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeMobileMarkdownRequest, RuntimeMobileMarkdownResponse @@ -98,6 +100,8 @@ import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' +import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' type NativeDropResolution = @@ -1631,6 +1635,11 @@ const api = { ipcRenderer.invoke('fs:createDir', args), rename: (args: { oldPath: string; newPath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:rename', args), + copy: (args: { + sourcePath: string + destinationPath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('fs:copy', args), deletePath: (args: { targetPath: string connectionId?: string @@ -1663,6 +1672,7 @@ const api = { sourcePaths: string[] destDir: string connectionId?: string + ensureDir?: boolean }): Promise<{ results: ( | { @@ -1684,6 +1694,32 @@ const api = { } )[] }> => ipcRenderer.invoke('fs:importExternalPaths', args), + stageExternalPathsForRuntimeUpload: (args: { + sourcePaths: string[] + }): Promise<{ + sources: ( + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: ( + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + )[] + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), resolveDroppedPathsForAgent: (args: { paths: string[] worktreePath: string @@ -2277,6 +2313,8 @@ const api = { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), + call: (args: { method: string; params?: unknown }): Promise> => + ipcRenderer.invoke('runtime:call', args), getTerminalFitOverrides: (): Promise< { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), @@ -2315,6 +2353,47 @@ const api = { } }, + runtimeEnvironments: { + list: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:list'), + addFromPairingCode: (args: { + name: string + pairingCode: string + }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), + resolve: (args: { selector: string }): Promise => + ipcRenderer.invoke('runtimeEnvironments:resolve', args), + remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:remove', args), + getStatus: (args: { + selector: string + timeoutMs?: number + }): Promise> => + ipcRenderer.invoke('runtimeEnvironments:getStatus', args), + call: (args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + }): Promise> => + ipcRenderer.invoke('runtimeEnvironments:call', args), + subscribe: async ( + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + }, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } + ): Promise => + subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) + }, + rateLimits: { get: (): Promise => ipcRenderer.invoke('rateLimits:get'), refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), diff --git a/src/preload/runtime-environment-subscriptions.test.ts b/src/preload/runtime-environment-subscriptions.test.ts new file mode 100644 index 00000000000..31c4447e616 --- /dev/null +++ b/src/preload/runtime-environment-subscriptions.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest' +import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + +describe('subscribeRuntimeEnvironmentFromPreload', () => { + it('registers the subscription event listener before invoking main', async () => { + const subscription = deferred<{ subscriptionId: string; requestId: string }>() + const invoke = vi.fn(() => subscription.promise as Promise) + const send = vi.fn() + const on = vi.fn() + const removeListener = vi.fn() + const onResponse = vi.fn() + + const cleanupPromise = subscribeRuntimeEnvironmentFromPreload( + { invoke, send, on, removeListener }, + { selector: 'desk', method: 'terminal.subscribe' }, + { onResponse }, + () => 'sub-1' + ) + + expect(on).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', expect.any(Function)) + expect(invoke).toHaveBeenCalledWith('runtimeEnvironments:subscribe', { + selector: 'desk', + method: 'terminal.subscribe', + subscriptionId: 'sub-1' + }) + + const listener = on.mock.calls[0][1] as ( + _event: unknown, + payload: { + subscriptionId: string + type: 'response' + response: { ok: true; id: string; result: unknown; _meta: { runtimeId: string } } + } + ) => void + listener(null, { + subscriptionId: 'sub-1', + type: 'response', + response: { + id: 'rpc-1', + ok: true, + result: { type: 'subscribed' }, + _meta: { runtimeId: 'rt' } + } + }) + expect(onResponse).toHaveBeenCalledWith({ + id: 'rpc-1', + ok: true, + result: { type: 'subscribed' }, + _meta: { runtimeId: 'rt' } + }) + + subscription.resolve({ subscriptionId: 'sub-1', requestId: 'rpc-1' }) + const cleanup = await cleanupPromise + const bytes = new Uint8Array([1, 2, 3]) + cleanup.sendBinary(bytes) + expect(send).toHaveBeenCalledWith('runtimeEnvironments:subscriptionBinary', { + subscriptionId: 'sub-1', + bytes + }) + cleanup.unsubscribe() + expect(removeListener).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', listener) + expect(invoke).toHaveBeenCalledWith('runtimeEnvironments:unsubscribe', { + subscriptionId: 'sub-1' + }) + }) + + it('removes the listener when main rejects the subscribe call', async () => { + const subscription = deferred<{ subscriptionId: string; requestId: string }>() + const invoke = vi.fn(() => subscription.promise as Promise) + const send = vi.fn() + const on = vi.fn() + const removeListener = vi.fn() + + const cleanupPromise = subscribeRuntimeEnvironmentFromPreload( + { invoke, send, on, removeListener }, + { selector: 'desk', method: 'terminal.subscribe' }, + { onResponse: vi.fn() }, + () => 'sub-2' + ) + + const error = new Error('subscribe failed') + subscription.reject(error) + await expect(cleanupPromise).rejects.toThrow(error) + expect(removeListener).toHaveBeenCalledWith( + 'runtimeEnvironments:subscriptionEvent', + on.mock.calls[0][1] + ) + }) +}) diff --git a/src/preload/runtime-environment-subscriptions.ts b/src/preload/runtime-environment-subscriptions.ts new file mode 100644 index 00000000000..d9c30325bd3 --- /dev/null +++ b/src/preload/runtime-environment-subscriptions.ts @@ -0,0 +1,99 @@ +import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' + +type RuntimeEnvironmentSubscribeArgs = { + selector: string + method: string + params?: unknown + timeoutMs?: number +} + +type RuntimeEnvironmentSubscriptionCallbacks = { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void +} + +export type RuntimeEnvironmentSubscriptionHandle = { + unsubscribe: () => void + sendBinary: (bytes: Uint8Array) => void +} + +type RuntimeEnvironmentSubscriptionEvent = + | { subscriptionId: string; type: 'response'; response: RuntimeRpcResponse } + | { subscriptionId: string; type: 'binary'; bytes: Uint8Array } + | { subscriptionId: string; type: 'error'; code: string; message: string } + | { subscriptionId: string; type: 'close' } + +type RuntimeEnvironmentSubscriptionIpc = { + invoke: (channel: string, args: unknown) => Promise + send: (channel: string, args: unknown) => void + on: ( + channel: string, + listener: (event: unknown, payload: RuntimeEnvironmentSubscriptionEvent) => void + ) => void + removeListener: ( + channel: string, + listener: (event: unknown, payload: RuntimeEnvironmentSubscriptionEvent) => void + ) => void +} + +const SUBSCRIPTION_EVENT_CHANNEL = 'runtimeEnvironments:subscriptionEvent' + +function createRuntimeEnvironmentSubscriptionId(): string { + const randomUuid = globalThis.crypto?.randomUUID + if (typeof randomUuid === 'function') { + return randomUuid.call(globalThis.crypto) + } + return `sub-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export async function subscribeRuntimeEnvironmentFromPreload( + ipc: RuntimeEnvironmentSubscriptionIpc, + args: RuntimeEnvironmentSubscribeArgs, + callbacks: RuntimeEnvironmentSubscriptionCallbacks, + createSubscriptionId = createRuntimeEnvironmentSubscriptionId +): Promise { + const subscriptionId = createSubscriptionId() + const listener = (_event: unknown, event: RuntimeEnvironmentSubscriptionEvent): void => { + if (event.subscriptionId !== subscriptionId) { + return + } + if (event.type === 'response') { + callbacks.onResponse(event.response) + } else if (event.type === 'binary') { + callbacks.onBinary?.(event.bytes) + } else if (event.type === 'error') { + callbacks.onError?.({ code: event.code, message: event.message }) + } else { + callbacks.onClose?.() + } + } + + // Why: streaming RPCs can emit their first frame before ipcMain.handle() + // resolves, so preload must subscribe to the event channel before invoking. + ipc.on(SUBSCRIPTION_EVENT_CHANNEL, listener) + try { + const result = (await ipc.invoke('runtimeEnvironments:subscribe', { + ...args, + subscriptionId + })) as { subscriptionId: string; requestId: string } + if (result.subscriptionId !== subscriptionId) { + ipc.removeListener(SUBSCRIPTION_EVENT_CHANNEL, listener) + throw new Error('Runtime environment subscription id mismatch') + } + } catch (error) { + ipc.removeListener(SUBSCRIPTION_EVENT_CHANNEL, listener) + throw error + } + + return { + unsubscribe: () => { + ipc.removeListener(SUBSCRIPTION_EVENT_CHANNEL, listener) + void ipc.invoke('runtimeEnvironments:unsubscribe', { subscriptionId }) + }, + sendBinary: (bytes) => { + ipc.send('runtimeEnvironments:subscriptionBinary', { subscriptionId, bytes }) + } + } +} diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index ed9120d54d6..590e9ea4665 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: this suite covers relay filesystem RPCs, + file watcher lifecycle edges, and cross-platform path behavior together. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { RelayContext } from './context' @@ -93,6 +95,7 @@ describe('FsHandler', () => { expect(methods).toContain('fs.deletePath') expect(methods).toContain('fs.createFile') expect(methods).toContain('fs.createDir') + expect(methods).toContain('fs.createDirNoClobber') expect(methods).toContain('fs.rename') expect(methods).toContain('fs.copy') expect(methods).toContain('fs.realpath') @@ -268,6 +271,13 @@ describe('FsHandler', () => { expect(stats.isDirectory()).toBe(true) }) + it('createDirNoClobber fails when the directory already exists', async () => { + const dirPath = path.join(tmpDir, 'existing') + mkdirSync(dirPath) + + await expect(dispatcher.callRequest('fs.createDirNoClobber', { dirPath })).rejects.toThrow() + }) + it('rename moves files', async () => { const oldPath = path.join(tmpDir, 'old.txt') const newPath = path.join(tmpDir, 'new.txt') @@ -291,6 +301,20 @@ describe('FsHandler', () => { expect(content).toBe('original') }) + it('copy does not overwrite an existing destination', async () => { + const src = path.join(tmpDir, 'src.txt') + const dst = path.join(tmpDir, 'dst.txt') + writeFileSync(src, 'original') + writeFileSync(dst, 'existing') + + await expect( + dispatcher.callRequest('fs.copy', { source: src, destination: dst }) + ).rejects.toThrow('EEXIST') + + const content = await fs.readFile(dst, 'utf-8') + expect(content).toBe('existing') + }) + it('realpath resolves symlinks', async () => { const realFile = path.join(tmpDir, 'real.txt') const linkPath = path.join(tmpDir, 'link.txt') @@ -332,4 +356,44 @@ describe('FsHandler', () => { dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir }) expect(secondUnsubscribe).toHaveBeenCalled() }) + + it('unsubscribes an active stale watch before replacing it', async () => { + const firstUnsubscribe = vi.fn() + const secondUnsubscribe = vi.fn() + mockSubscribe + .mockResolvedValueOnce({ unsubscribe: firstUnsubscribe }) + .mockResolvedValueOnce({ unsubscribe: secondUnsubscribe }) + + let stale = false + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => stale }) + stale = true + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false }) + + expect(firstUnsubscribe).toHaveBeenCalled() + expect(secondUnsubscribe).not.toHaveBeenCalled() + dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir }) + expect(secondUnsubscribe).toHaveBeenCalled() + }) + + it('replaces a stale watch for the same root before enforcing the watch cap', async () => { + const firstUnsubscribe = vi.fn() + const replacementUnsubscribe = vi.fn() + mockSubscribe + .mockResolvedValueOnce({ unsubscribe: firstUnsubscribe }) + .mockResolvedValueOnce({ unsubscribe: replacementUnsubscribe }) + + let stale = false + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => stale }) + for (let index = 0; index < 19; index++) { + await dispatcher.callRequest('fs.watch', { + rootPath: path.join(tmpDir, `watched-${index}`) + }) + } + + stale = true + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false }) + + expect(firstUnsubscribe).toHaveBeenCalled() + expect(replacementUnsubscribe).not.toHaveBeenCalled() + }) }) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 6f29861826c..3bedb3c856b 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: relay filesystem request handling shares + path expansion, file IO, search, streaming reads, and watch lifecycle state. */ import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'fs/promises' import { execFile } from 'child_process' import { join } from 'path' @@ -64,6 +66,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.deletePath', (p) => this.deletePath(p)) this.dispatcher.onRequest('fs.createFile', (p) => this.createFile(p)) this.dispatcher.onRequest('fs.createDir', (p) => this.createDir(p)) + this.dispatcher.onRequest('fs.createDirNoClobber', (p) => this.createDirNoClobber(p)) this.dispatcher.onRequest('fs.rename', (p) => this.rename(p)) this.dispatcher.onRequest('fs.copy', (p) => this.copy(p)) this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) @@ -175,6 +178,11 @@ export class FsHandler { await mkdir(dirPath, { recursive: true }) } + private async createDirNoClobber(params: Record) { + const dirPath = expandTilde(params.dirPath as string) + await mkdir(dirPath, { recursive: false }) + } + private async rename(params: Record) { const oldPath = expandTilde(params.oldPath as string) const newPath = expandTilde(params.newPath as string) @@ -184,7 +192,15 @@ export class FsHandler { private async copy(params: Record) { const source = expandTilde(params.source as string) const destination = expandTilde(params.destination as string) - await cp(source, destination, { recursive: true }) + try { + await cp(source, destination, { recursive: true, force: false, errorOnExist: true }) + } catch (error) { + const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined + if (code === 'EEXIST' || code === 'ERR_FS_CP_EEXIST') { + throw new Error('EEXIST: destination already exists') + } + throw error + } } private async realpath(params: Record) { @@ -266,10 +282,6 @@ export class FsHandler { private async watch(params: Record, context?: RequestContext) { const rootPath = expandTilde(params.rootPath as string) - if (this.watches.size >= 20) { - throw new Error('Maximum number of file watchers reached') - } - const existing = this.watches.get(rootPath) if (existing && !existing.isStale()) { if (existing.setupPromise) { @@ -277,6 +289,14 @@ export class FsHandler { } return } + if (existing?.isStale()) { + existing.unwatchFn?.() + this.watches.delete(rootPath) + } + + if (this.watches.size >= 20) { + throw new Error('Maximum number of file watchers reached') + } const watchState: WatchState = { rootPath, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b70bbbd11c0..68642e47ec1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -368,6 +368,10 @@ function App(): React.JSX.Element { let reconnectStarted = false void (async () => { try { + // Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId. + // Load settings first so a persisted remote runtime does not boot against + // the local filesystem and then hydrate stale local workspace state. + await actions.fetchSettings() await actions.fetchRepos() await actions.fetchAllWorktrees() const persistedUI = await window.api.ui.get() @@ -377,10 +381,6 @@ function App(): React.JSX.Element { hydratePersistedUI: actions.hydratePersistedUI }) const session = await window.api.session.get() - // Why: settings must be loaded before hydrateWorkspaceSession so that - // hydration has access to user preferences. Without this, settings - // would still be null at hydration time. - await actions.fetchSettings() if (!cancelled) { actions.hydrateWorkspaceSession(session) actions.hydrateTabsSession(session) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts new file mode 100644 index 00000000000..67e92fca0a4 --- /dev/null +++ b/src/renderer/src/app-startup-routing.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('renderer startup runtime routing', () => { + it('loads settings before repo and worktree hydration', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const startupBlockStart = source.indexOf('void (async () => {') + const startupBlockEnd = source.indexOf('const persistedUI = await window.api.ui.get()') + const startupBlock = source.slice(startupBlockStart, startupBlockEnd) + + expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeGreaterThanOrEqual(0) + expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeLessThan( + startupBlock.indexOf('await actions.fetchRepos()') + ) + expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeLessThan( + startupBlock.indexOf('await actions.fetchAllWorktrees()') + ) + }) +}) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index b8cd117d62e..0e735654c76 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -70,6 +70,7 @@ import { type PRCommentGroup } from '@/lib/pr-comment-groups' import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' @@ -371,6 +372,7 @@ function findNearestBraceBlock( type FileRowProps = { file: GitHubPRFile + repoId?: string repoPath: string prNumber: number headSha: string | undefined @@ -384,6 +386,7 @@ type DiffViewMode = 'flat' | 'tree' type DiffTreeNodeProps = { node: DiffTreeNode depth: number + repoId?: string repoPath: string prNumber: number headSha: string | undefined @@ -394,6 +397,7 @@ type DiffTreeNodeProps = { function PRDiffTreeNode({ node, depth, + repoId, repoPath, prNumber, headSha, @@ -406,6 +410,7 @@ function PRDiffTreeNode({ return ( ( + target, + 'github.prFileContents', + { + repo: args.repoId, + prNumber: args.prNumber, + path: args.file.path, + oldPath: args.file.oldPath, + status: args.file.status, + headSha: args.headSha, + baseSha: args.baseSha + }, + { timeoutMs: 30_000 } + ) + : window.api.gh.prFileContents({ + repoPath: args.repoPath, + prNumber: args.prNumber, + path: args.file.path, + oldPath: args.file.oldPath, + status: args.file.status, + headSha: args.headSha, + baseSha: args.baseSha + }) + ) .then((contents) => { touchPRFileContentCache(cacheKey, contents) return contents @@ -700,8 +735,143 @@ function loadPRFileContents(args: { return request } +function getRuntimeTargetForRepoId(repoId: string | null | undefined) { + if (!repoId) { + return null + } + const state = useAppStore.getState() + const target = getActiveRuntimeTarget(state.settings) + if (target.kind !== 'environment') { + return null + } + return state.repos.some((repo) => repo.id === repoId) ? target : null +} + +function addIssueCommentForRepo(args: { + repoId?: string + repoPath: string + number: number + body: string + type?: 'issue' | 'pr' +}): Promise>> { + const target = getRuntimeTargetForRepoId(args.repoId) + if (target) { + return callRuntimeRpc>>( + target, + 'github.addIssueComment', + { repo: args.repoId, number: args.number, body: args.body, type: args.type }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.addIssueComment({ + repoPath: args.repoPath, + number: args.number, + body: args.body, + type: args.type + }) +} + +function addPRReviewCommentForRepo(args: { + repoId?: string + repoPath: string + prNumber: number + commitId: string + path: string + line: number + startLine?: number + body: string +}): Promise>> { + const target = getRuntimeTargetForRepoId(args.repoId) + if (target) { + return callRuntimeRpc>>( + target, + 'github.addPRReviewComment', + { + repo: args.repoId, + prNumber: args.prNumber, + commitId: args.commitId, + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body + }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.addPRReviewComment({ + repoPath: args.repoPath, + prNumber: args.prNumber, + commitId: args.commitId, + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body + }) +} + +function addPRReviewCommentReplyForRepo(args: { + repoId?: string + repoPath: string + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number +}): Promise>> { + const target = getRuntimeTargetForRepoId(args.repoId) + if (target) { + return callRuntimeRpc>>( + target, + 'github.addPRReviewCommentReply', + { + repo: args.repoId, + prNumber: args.prNumber, + commentId: args.commentId, + body: args.body, + threadId: args.threadId, + path: args.path, + line: args.line + }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.addPRReviewCommentReply({ + repoPath: args.repoPath, + prNumber: args.prNumber, + commentId: args.commentId, + body: args.body, + threadId: args.threadId, + path: args.path, + line: args.line + }) +} + +function getWorkItemDetailsForRepo(args: { + repoId?: string + repoPath: string + number: number + type: 'issue' | 'pr' +}): Promise { + const target = getRuntimeTargetForRepoId(args.repoId) + if (target) { + return callRuntimeRpc( + target, + 'github.workItemDetails', + { repo: args.repoId, number: args.number, type: args.type }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.workItemDetails({ + repoPath: args.repoPath, + number: args.number, + type: args.type + }) +} + function PRFileRow({ file, + repoId, repoPath, prNumber, headSha, @@ -728,6 +898,7 @@ function PRFileRow({ setLoading(true) setError(null) loadPRFileContents({ + repoId, repoPath, prNumber, file, @@ -746,7 +917,7 @@ function PRFileRow({ } return next }) - }, [baseSha, canLoadDiff, contents, file, headSha, loading, prNumber, repoPath]) + }, [baseSha, canLoadDiff, contents, file, headSha, loading, prNumber, repoId, repoPath]) const language = useMemo(() => detectLanguage(file.path), [file.path]) const modelKey = `gh-dialog:pr:${prNumber}:${file.path}` @@ -764,7 +935,8 @@ function PRFileRow({ toast.error('Unable to comment without the PR head SHA.') return false } - const result = await window.api.gh.addPRReviewComment({ + const result = await addPRReviewCommentForRepo({ + repoId, repoPath, prNumber, commitId: headSha, @@ -781,7 +953,7 @@ function PRFileRow({ toast.success('Review comment added.') return true }, - [file.path, headSha, onCommentAdded, prNumber, repoPath] + [file.path, headSha, onCommentAdded, prNumber, repoId, repoPath] ) return ( @@ -1149,6 +1321,7 @@ function CommentCodeContext({ function ConversationTab({ item, + repoId, repoPath, body, comments, @@ -1162,6 +1335,7 @@ function ConversationTab({ onCommentAdded }: { item: GitHubWorkItem + repoId?: string repoPath: string | null body: string comments: PRComment[] @@ -1174,10 +1348,11 @@ function ConversationTab({ onUse: (item: GitHubWorkItem) => void onCommentAdded: (comment: PRComment) => void }): React.JSX.Element { + const settings = useAppStore((s) => s.settings) const authorLabel = item.author ?? 'unknown' const [replyingTo, setReplyingTo] = useState(null) const [commentFilter, setCommentFilter] = useState('all') - const repoAssignees = useRepoAssignees(repoPath) + const repoAssignees = useRepoAssignees(repoPath, repoId, settings) const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) const visibleComments = useMemo( () => filterPRCommentsByAudience(comments, commentFilter), @@ -1209,7 +1384,8 @@ function ConversationTab({ } const result = comment.path && item.type === 'pr' - ? await window.api.gh.addPRReviewCommentReply({ + ? await addPRReviewCommentReplyForRepo({ + repoId, repoPath, prNumber: item.number, commentId: comment.id, @@ -1218,7 +1394,8 @@ function ConversationTab({ path: comment.path, line: comment.line }) - : await window.api.gh.addIssueComment({ + : await addIssueCommentForRepo({ + repoId, repoPath, number: item.number, body: `@${comment.author} ${replyBody}`, @@ -1234,7 +1411,7 @@ function ConversationTab({ toast.success('Reply posted.') return true }, - [item.number, item.type, onCommentAdded, repoPath] + [item.number, item.type, onCommentAdded, repoId, repoPath] ) const startWorkspaceButton = ( @@ -1483,6 +1660,7 @@ function ConversationTab({ {repoPath && ( [0]['updates'] }): Promise { if (args.projectOrigin) { - const res = await window.api.gh.updateIssueBySlug({ + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, number: args.number, updates: args.updates - }) + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.project.updateIssueBySlug', + updateArgs, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueBySlug(updateArgs) if (!res.ok) { throw new Error(res.error.message) } @@ -1818,6 +2007,19 @@ async function runIssueUpdate(args: { if (!args.repoPath) { throw new Error('No repo context available for this edit.') } + const target = getRuntimeTargetForRepoId(args.repoId) + if (target) { + const res = await callRuntimeRpc>>( + target, + 'github.updateIssue', + { repo: args.repoId, number: args.number, updates: args.updates }, + { timeoutMs: 30_000 } + ) + if (!res.ok) { + throw new Error(res.error) + } + return + } await window.api.gh.updateIssue({ repoPath: args.repoPath, number: args.number, @@ -1858,6 +2060,7 @@ function GHEditSection({ const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const { isPending, run } = useImmediateMutation() + const settings = useAppStore((s) => s.settings) // Why: when the dialog opens from a Project view, mutations route through // *BySlug IPCs and we must keep `projectViewCache` in sync alongside @@ -1879,11 +2082,19 @@ function GHEditSection({ // values from a different repo than the writes target. const slugOwner = projectOrigin?.owner ?? null const slugRepo = projectOrigin?.repo ?? null - const repoLabelsByPath = useRepoLabels(projectOrigin ? null : repoPath) - const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo) + const repoLabelsByPath = useRepoLabels( + projectOrigin ? null : repoPath, + projectOrigin ? null : item.repoId, + settings + ) + const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, settings) const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath - const repoAssigneesByPath = useRepoAssignees(projectOrigin ? null : repoPath) - const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees) + const repoAssigneesByPath = useRepoAssignees( + projectOrigin ? null : repoPath, + projectOrigin ? null : item.repoId, + settings + ) + const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees, settings) const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath // Why: sync local assignees when item changes or when the detail fetch @@ -1910,6 +2121,7 @@ function GHEditSection({ run('state', { mutate: () => runIssueUpdate({ + repoId: item.repoId, repoPath, projectOrigin, number: item.number, @@ -1936,6 +2148,7 @@ function GHEditSection({ [ item.id, item.number, + item.repoId, localState, repoPath, projectOrigin, @@ -1957,6 +2170,7 @@ function GHEditSection({ run('labels', { mutate: () => runIssueUpdate({ + repoId: item.repoId, repoPath, projectOrigin, number: item.number, @@ -1981,6 +2195,7 @@ function GHEditSection({ run('labels', { mutate: () => runIssueUpdate({ + repoId: item.repoId, repoPath, projectOrigin, number: item.number, @@ -2006,6 +2221,7 @@ function GHEditSection({ [ item.id, item.number, + item.repoId, localLabels, repoPath, projectOrigin, @@ -2030,6 +2246,7 @@ function GHEditSection({ run('assignees', { mutate: () => runIssueUpdate({ + repoId: item.repoId, repoPath, projectOrigin, number: item.number, @@ -2052,6 +2269,7 @@ function GHEditSection({ run('assignees', { mutate: () => runIssueUpdate({ + repoId: item.repoId, repoPath, projectOrigin, number: item.number, @@ -2072,7 +2290,16 @@ function GHEditSection({ }) } }, - [item.number, repoPath, projectOrigin, localAssignees, patchProjectRowIfNeeded, run, onMutated] + [ + item.number, + item.repoId, + repoPath, + projectOrigin, + localAssignees, + patchProjectRowIfNeeded, + run, + onMutated + ] ) if (item.type === 'pr') { @@ -2267,6 +2494,7 @@ function GHEditSection({ function GHCommentComposer({ className, + repoId, repoPath, issueNumber, itemType, @@ -2274,6 +2502,7 @@ function GHCommentComposer({ onCommentAdded }: { className?: string + repoId?: string repoPath: string issueNumber: number itemType: 'issue' | 'pr' @@ -2300,7 +2529,8 @@ function GHCommentComposer({ } setSubmitting(true) try { - const result = await window.api.gh.addIssueComment({ + const result = await addIssueCommentForRepo({ + repoId, repoPath, number: issueNumber, body: trimmed, @@ -2320,7 +2550,7 @@ function GHCommentComposer({ } finally { setSubmitting(false) } - }, [autoGrow, body, repoPath, issueNumber, itemType, onCommentAdded]) + }, [autoGrow, body, repoId, repoPath, issueNumber, itemType, onCommentAdded]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -2453,17 +2683,23 @@ export default function GitHubItemDialog({ } return s.repos.find((r) => r.path === repoPath)?.issueSourcePreference }) + const runtimeScope = useAppStore((s) => + s.settings?.activeRuntimeEnvironmentId + ? `runtime:${s.settings.activeRuntimeEnvironmentId}` + : 'local' + ) const detailsCacheKey = useMemo(() => { if (!workItem || !repoPath) { return null } return getWorkItemDetailsCacheKey({ + runtimeScope, repoPath, issueSourcePreference, type: workItem.type, number: workItem.number }) - }, [repoPath, workItem, issueSourcePreference]) + }, [runtimeScope, repoPath, workItem, issueSourcePreference]) // Why: reset lifted edit state when the dialog switches items or when the // same item receives an optimistic cache patch from the surrounding table. @@ -2605,7 +2841,8 @@ export default function GitHubItemDialog({ // racing two `gh` subprocesses against each other. const inflight: Promise = cached?.pending ?? - window.api.gh.workItemDetails({ + getWorkItemDetailsForRepo({ + repoId: workItem.repoId, repoPath, number: workItem.number, type: workItem.type @@ -2859,6 +3096,7 @@ export default function GitHubItemDialog({ s.patchLinearIssue) + const settings = useAppStore((s) => s.settings) const { isPending, run } = useImmediateMutation() const { @@ -98,9 +105,9 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): } = editState const teamId = issue.team?.id || null - const states = useTeamStates(teamId) - const labels = useTeamLabels(teamId) - const members = useTeamMembers(teamId) + const states = useTeamStates(teamId, settings) + const labels = useTeamLabels(teamId, settings) + const members = useTeamMembers(teamId, settings) const handleStateChange = useCallback( (stateId: string) => { @@ -113,7 +120,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): const stateValue = { name: newState.name, type: newState.type, color: newState.color } run('state', { - mutate: () => window.api.linear.updateIssue({ id: issue.id, updates: { stateId } }), + mutate: () => linearUpdateIssue(settings, issue.id, { stateId }), onOptimistic: () => { onEditStateChange({ state: stateValue }) patchLinearIssue(issue.id, { state: stateValue }) @@ -125,7 +132,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localState, states.data, patchLinearIssue, run, onEditStateChange] + [issue.id, localState, settings, states.data, patchLinearIssue, run, onEditStateChange] ) const handlePriorityChange = useCallback( @@ -133,7 +140,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): const priority = parseInt(value, 10) const prevPriority = localPriority run('priority', { - mutate: () => window.api.linear.updateIssue({ id: issue.id, updates: { priority } }), + mutate: () => linearUpdateIssue(settings, issue.id, { priority }), onOptimistic: () => { onEditStateChange({ priority }) patchLinearIssue(issue.id, { priority }) @@ -145,7 +152,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localPriority, patchLinearIssue, run, onEditStateChange] + [issue.id, localPriority, settings, patchLinearIssue, run, onEditStateChange] ) const handleAssigneeChange = useCallback( @@ -157,7 +164,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): ? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl } : undefined run('assignee', { - mutate: () => window.api.linear.updateIssue({ id: issue.id, updates: { assigneeId } }), + mutate: () => linearUpdateIssue(settings, issue.id, { assigneeId }), onOptimistic: () => { onEditStateChange({ assignee: newAssignee }) patchLinearIssue(issue.id, { assignee: newAssignee }) @@ -169,7 +176,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localAssignee, members.data, patchLinearIssue, run, onEditStateChange] + [issue.id, localAssignee, settings, members.data, patchLinearIssue, run, onEditStateChange] ) const handleLabelToggle = useCallback( @@ -185,8 +192,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): .filter((n): n is string => !!n) run('labels', { - mutate: () => - window.api.linear.updateIssue({ id: issue.id, updates: { labelIds: newLabelIds } }), + mutate: () => linearUpdateIssue(settings, issue.id, { labelIds: newLabelIds }), onOptimistic: () => { onEditStateChange({ labelIds: newLabelIds, labels: newLabels }) patchLinearIssue(issue.id, { labelIds: newLabelIds, labels: newLabels }) @@ -198,7 +204,16 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localLabelIds, localLabels, labels.data, patchLinearIssue, run, onEditStateChange] + [ + issue.id, + localLabelIds, + localLabels, + settings, + labels.data, + patchLinearIssue, + run, + onEditStateChange + ] ) const currentStateId = states.data.find( @@ -406,6 +421,7 @@ function CommentFooter({ issueId: string onCommentAdded: (comment: LocalComment) => void }): React.JSX.Element { + const settings = useAppStore((s) => s.settings) const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) @@ -426,7 +442,7 @@ function CommentFooter({ } setSubmitting(true) try { - const result = await window.api.linear.addIssueComment({ issueId, body: trimmed }) + const result = await linearAddIssueComment(settings, issueId, trimmed) const typed = result as { ok: boolean; id?: string; error?: string } if (typed.ok) { setBody('') @@ -443,7 +459,7 @@ function CommentFooter({ } finally { setSubmitting(false) } - }, [body, issueId, onCommentAdded]) + }, [body, issueId, onCommentAdded, settings]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -508,6 +524,7 @@ export default function LinearItemDrawer({ const requestIdRef = useRef(0) const hasEditedRef = useRef(false) const optimisticCommentsRef = useRef([]) + const settings = useAppStore((s) => s.settings) const handleEditStateChange = useCallback((patch: Partial) => { hasEditedRef.current = true @@ -535,8 +552,7 @@ export default function LinearItemDrawer({ // Why: fetch issue and comments independently so a transient comments // failure doesn't discard the successfully-fetched issue data. - window.api.linear - .getIssue({ id: issue.id }) + linearGetIssue(settings, issue.id) .then((issueResult) => { if (requestId !== requestIdRef.current) { return @@ -553,8 +569,7 @@ export default function LinearItemDrawer({ }) .catch(() => {}) - window.api.linear - .issueComments({ issueId: issue.id }) + linearIssueComments(settings, issue.id) .then((commentsResult) => { if (requestId !== requestIdRef.current) { return @@ -579,7 +594,7 @@ export default function LinearItemDrawer({ } }) // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [issue?.id]) + }, [issue?.id, settings]) // Why: same pointer-events fix as GitHubItemDialog — Radix may leave // pointer-events: none on body when overlays transition. diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index f18a3203b59..53d0e646c36 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -6,6 +6,7 @@ import { useActiveWorktree, useWorktreesForRepo } from '@/store/selectors' import { detectLanguage } from '@/lib/language-detect' import { joinPath } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' +import { listRuntimeFiles } from '@/runtime/runtime-file-client' import { CommandDialog, CommandInput, @@ -254,15 +255,18 @@ export default function QuickOpen(): React.JSX.Element | null { const excludePaths = excludePathsKey ? excludePathsKey.split('\n') : undefined - void window.api.fs - // Why: quick-open shares the active worktree path model with file explorer - // and search, so remote worktrees must include connectionId. Without this, - // Windows resolves Linux roots (e.g. /home/*) as local C:\home\* paths. - .listFiles({ + void listRuntimeFiles( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + { rootPath: worktreePath, - connectionId, excludePaths - }) + } + ) .then((result) => { if (!cancelled) { setFiles(result) @@ -289,7 +293,7 @@ export default function QuickOpen(): React.JSX.Element | null { return () => { cancelled = true } - }, [visible, worktreePath, connectionId, excludePathsKey, filesRequestKey]) + }, [visible, activeWorktreeId, worktreePath, connectionId, excludePathsKey, filesRequestKey]) // Filter files by fuzzy match const filtered = useMemo(() => { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 7243620838b..3bbc8a03af0 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -26,6 +26,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { useRepoMap } from '@/store/selectors' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { @@ -85,9 +86,16 @@ import type { GitLabTodo, GitLabWorkItem, LinearIssue, + Repo, TaskViewPresetId } from '../../../shared/types' import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' +import { + linearCreateIssue, + linearGetIssue, + linearListTeams, + linearUpdateIssue +} from '@/runtime/runtime-linear-client' type TaskSource = 'github' | 'linear' | 'gitlab' @@ -105,6 +113,18 @@ type TaskQueryPreset = { query: string } +function getRuntimeTargetForRepoId(repoId: string | null | undefined) { + if (!repoId) { + return null + } + const state = useAppStore.getState() + const target = getActiveRuntimeTarget(state.settings) + if (target.kind !== 'environment') { + return null + } + return state.repos.some((repo) => repo.id === repoId) ? target : null +} + type SourceOption = { id: TaskSource label: string @@ -219,10 +239,10 @@ const LINEAR_PRIORITY_LABELS: Record = { function GHStatusCell({ item, - repoPath + repo }: { item: GitHubWorkItem - repoPath: string | null + repo: Repo | null }): React.JSX.Element { const patchWorkItem = useAppStore((s) => s.patchWorkItem) const [localState, setLocalState] = useState(item.state) @@ -235,15 +255,28 @@ function GHStatusCell({ const handleStateChange = useCallback( (newState: 'open' | 'closed') => { - if (newState === localState || !repoPath || item.type !== 'issue') { + if (newState === localState || !repo || item.type !== 'issue') { return } reqRef.current += 1 const reqId = reqRef.current setLocalState(newState) patchWorkItem(item.id, { state: newState }) - window.api.gh - .updateIssue({ repoPath, number: item.number, updates: { state: newState } }) + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const updatePromise = + target.kind === 'environment' + ? callRuntimeRpc<{ ok?: boolean; error?: string }>( + target, + 'github.updateIssue', + { repo: repo.id, number: item.number, updates: { state: newState } }, + { timeoutMs: 30_000 } + ) + : window.api.gh.updateIssue({ + repoPath: repo.path, + number: item.number, + updates: { state: newState } + }) + updatePromise .then((result) => { if (reqId !== reqRef.current) { return @@ -264,10 +297,10 @@ function GHStatusCell({ toast.error('Failed to update state') }) }, - [item.id, item.number, item.type, localState, repoPath, patchWorkItem] + [item.id, item.number, item.type, localState, repo, patchWorkItem] ) - if (item.type !== 'issue' || !repoPath) { + if (item.type !== 'issue' || !repo) { return ( s.patchLinearIssue) const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) + const settings = useAppStore((s) => s.settings) const [localState, setLocalState] = useState(issue.state) const reqRef = useRef(0) @@ -342,7 +376,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element }, [issue.state]) const teamId = issue.team?.id || null - const states = useTeamStates(teamId) + const states = useTeamStates(teamId, settings) const handleStateChange = useCallback( (stateId: string) => { @@ -357,8 +391,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element setLocalState(stateValue) patchLinearIssue(issue.id, { state: stateValue }) - window.api.linear - .updateIssue({ id: issue.id, updates: { stateId } }) + linearUpdateIssue(settings, issue.id, { stateId }) .then((result) => { if (reqId !== reqRef.current) { return @@ -381,7 +414,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element toast.error('Failed to update status') }) }, - [issue.id, issue.state, states.data, patchLinearIssue, fetchLinearIssue] + [issue.id, issue.state, settings, states.data, patchLinearIssue, fetchLinearIssue] ) const currentStateId = states.data.find( @@ -442,6 +475,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element function LinearPriorityCell({ issue }: { issue: LinearIssue }): React.JSX.Element { const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) + const settings = useAppStore((s) => s.settings) const [localPriority, setLocalPriority] = useState(issue.priority) const [pending, setPending] = useState(false) const reqRef = useRef(0) @@ -460,8 +494,7 @@ function LinearPriorityCell({ issue }: { issue: LinearIssue }): React.JSX.Elemen setLocalPriority(priority) patchLinearIssue(issue.id, { priority }) setPending(true) - window.api.linear - .updateIssue({ id: issue.id, updates: { priority } }) + linearUpdateIssue(settings, issue.id, { priority }) .then((result) => { if (reqId !== reqRef.current) { return @@ -490,7 +523,7 @@ function LinearPriorityCell({ issue }: { issue: LinearIssue }): React.JSX.Elemen setPending(false) }) }, - [issue.id, issue.priority, localPriority, patchLinearIssue, fetchLinearIssue] + [issue.id, issue.priority, localPriority, settings, patchLinearIssue, fetchLinearIssue] ) const [open, setOpen] = useState(false) @@ -1066,14 +1099,13 @@ export default function TaskPage(): React.JSX.Element { if (taskSource !== 'linear' || !linearStatus.connected) { return } - void window.api.linear - .listTeams() + void linearListTeams(settings) .then(setAvailableTeams) .catch(() => { console.warn('[TaskPage] Failed to fetch Linear teams') }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [taskSource, linearStatus.connected, taskResumeApplied]) + }, [settings, taskSource, linearStatus.connected, taskResumeApplied]) // Why: stable key for `selectedRepos` so the GitLab fetch effect below // doesn't re-run on every parent re-render just because the array @@ -1619,11 +1651,19 @@ export default function TaskPage(): React.JSX.Element { } setNewIssueSubmitting(true) try { - const result = await window.api.gh.createIssue({ - repoPath: newIssueTargetRepo.path, - title, - body: newIssueBody - }) + const target = getRuntimeTargetForRepoId(newIssueTargetRepo.id) + const result = target + ? await callRuntimeRpc>>( + target, + 'github.createIssue', + { repo: newIssueTargetRepo.id, title, body: newIssueBody }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.createIssue({ + repoPath: newIssueTargetRepo.path, + title, + body: newIssueBody + }) if (!result.ok) { toast.error(result.error || 'Failed to create issue.') return @@ -1659,8 +1699,19 @@ export default function TaskPage(): React.JSX.Element { } setDialogWorkItem(stub) const stubRepoId = newIssueTargetRepo.id - void window.api.gh - .workItem({ repoPath: newIssueTargetRepo.path, number: result.number, type: 'issue' }) + const fullIssuePromise = target + ? callRuntimeRpc>>( + target, + 'github.workItem', + { repo: newIssueTargetRepo.id, number: result.number, type: 'issue' }, + { timeoutMs: 30_000 } + ) + : window.api.gh.workItem({ + repoPath: newIssueTargetRepo.path, + number: result.number, + type: 'issue' + }) + void fullIssuePromise .then((full) => { if (full) { // Why: `full` is `Omit` (IPC shape). @@ -1687,7 +1738,7 @@ export default function TaskPage(): React.JSX.Element { } setNewLinearIssueSubmitting(true) try { - const result = await window.api.linear.createIssue({ + const result = await linearCreateIssue(settings, { teamId: newLinearIssueTargetTeam.id, title, description: newLinearIssueBody || undefined @@ -1711,8 +1762,7 @@ export default function TaskPage(): React.JSX.Element { // Why: auto-open the new issue in the side drawer so the user sees // exactly what was filed, mirroring the GitHub create-issue flow. - void window.api.linear - .getIssue({ id: result.id }) + void linearGetIssue(settings, result.id) .then((full) => { if (full) { setDrawerLinearIssue(full) @@ -1727,6 +1777,7 @@ export default function TaskPage(): React.JSX.Element { newLinearIssueSubmitting, newLinearIssueTargetTeam, newLinearIssueTitle, + settings, setDrawerLinearIssue ]) @@ -2693,7 +2744,7 @@ export default function TaskPage(): React.JSX.Element {
- +
diff --git a/src/renderer/src/components/TelemetryFirstLaunchSurface.tsx b/src/renderer/src/components/TelemetryFirstLaunchSurface.tsx index 50cedad4963..29c9cafd31d 100644 --- a/src/renderer/src/components/TelemetryFirstLaunchSurface.tsx +++ b/src/renderer/src/components/TelemetryFirstLaunchSurface.tsx @@ -1,6 +1,7 @@ // Root-mounted gate for the existing-user first-launch notice. New users -// get NO first-launch surface; see telemetry-plan.md §First-launch experience -// for the rationale. +// get NO first-launch surface — default-on with no first-run notice +// matches the category norm for developer tooling; see telemetry-plan.md +// §First-launch experience for the rationale. // // Cohort marker populated by the migration in `src/main/persistence.ts`: // diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 4d5f987b116..d1b172000dd 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -62,6 +62,7 @@ import { useActivityTerminalPortals, type ActivityTerminalPortalTarget } from './activity/activity-terminal-portal' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -215,7 +216,9 @@ function Terminal(): React.JSX.Element | null { if (connectionId !== null) { return [] } - return worktreeTabs.flatMap((tab) => state.ptyIdsByTabId[tab.id] ?? []) + return worktreeTabs + .flatMap((tab) => state.ptyIdsByTabId[tab.id] ?? []) + .filter((ptyId) => !isRemoteRuntimePtyId(ptyId)) } ) if (localPtyIds.length > 0) { @@ -640,10 +643,12 @@ function Terminal(): React.JSX.Element | null { // the ambient/default group and open the file in the wrong pane. const targetGroupId = useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const settings = useAppStore.getState().settings const fileInfo = await createUntitledMarkdownFile( worktree.path, activeWorktreeId, - connectionId + connectionId, + settings ) openFile(fileInfo, { preview: false, targetGroupId }) } catch (err) { diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 1589c555332..7584be239bd 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -23,6 +23,7 @@ import { cn } from '@/lib/utils' import { getWorktreeStatus, getWorktreeStatusLabel } from '@/lib/worktree-status' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { searchWorktrees, type MatchRange, @@ -38,7 +39,13 @@ import { ORCA_BROWSER_FOCUS_REQUEST_EVENT, queueBrowserFocusRequest } from '@/components/browser-pane/browser-focus' -import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' +import type { + BrowserPage, + BrowserWorkspace, + GitHubWorkItem, + Repo, + Worktree +} from '../../../shared/types' import { isGitRepoKind } from '../../../shared/repo-kind' type WorktreePaletteItem = { @@ -76,6 +83,55 @@ type BrowserSelection = { page: BrowserPage } +type GitHubWorkItemWithoutRepo = Omit + +function getRuntimeTargetForRepo( + repo: Repo +): { kind: 'environment'; environmentId: string } | null { + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + if (target.kind !== 'environment') { + return null + } + return useAppStore.getState().repos.some((candidate) => candidate.id === repo.id) ? target : null +} + +function getWorkItemForRepo(repo: Repo, number: number): Promise { + const target = getRuntimeTargetForRepo(repo) + if (target) { + return callRuntimeRpc( + target, + 'github.workItem', + { repo: repo.id, number }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.workItem({ repoPath: repo.path, number }) +} + +function getWorkItemByOwnerRepoForRepo( + repo: Repo, + slug: { owner: string; repo: string }, + number: number, + type: 'issue' | 'pr' +): Promise { + const target = getRuntimeTargetForRepo(repo) + if (target) { + return callRuntimeRpc( + target, + 'github.workItemByOwnerRepo', + { repo: repo.id, owner: slug.owner, ownerRepo: slug.repo, number, type }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.workItemByOwnerRepo({ + repoPath: repo.path, + owner: slug.owner, + repo: slug.repo, + number, + type + }) +} + function HighlightedText({ text, matchRange @@ -675,8 +731,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // indefinitely on slow networks. Close immediately and populate the // composer once the lookup returns. closeModal() - void window.api.gh - .workItem({ repoPath: repoForLookup.path, number }) + void getWorkItemByOwnerRepoForRepo(repoForLookup, slug, number, ghLink.type) .then((item) => { const data: Record = { initialRepoId: repoForLookup.id } if (item) { @@ -729,8 +784,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } closeModal() - void window.api.gh - .workItem({ repoPath: repoForLookup.path, number: ghNumber }) + void getWorkItemForRepo(repoForLookup, ghNumber) .then((item) => { const data: Record = { initialRepoId: repoForLookup.id } if (item) { diff --git a/src/renderer/src/components/automations/CreateFromPicker.tsx b/src/renderer/src/components/automations/CreateFromPicker.tsx index e3f4bb7db23..300655e5f7b 100644 --- a/src/renderer/src/components/automations/CreateFromPicker.tsx +++ b/src/renderer/src/components/automations/CreateFromPicker.tsx @@ -11,6 +11,11 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import type { Repo, Worktree } from '../../../../shared/types' +import { useAppStore } from '@/store' +import { + getRuntimeRepoBaseRefDefault, + searchRuntimeRepoBaseRefs +} from '@/runtime/runtime-repo-client' const DEFAULT_VALUE = '__project_default__' @@ -31,6 +36,9 @@ export function CreateFromPicker({ value: string onValueChange: (baseBranch: string) => void }): React.JSX.Element { + const activeRuntimeEnvironmentId = useAppStore( + (state) => state.settings?.activeRuntimeEnvironmentId ?? null + ) const repo = repoMap.get(repoId) const [open, setOpen] = React.useState(false) const inputRef = React.useRef(null) @@ -73,8 +81,7 @@ export function CreateFromPicker({ } let stale = false setDefaultBaseRef(null) - void window.api.repos - .getBaseRefDefault({ repoId }) + void getRuntimeRepoBaseRefDefault({ activeRuntimeEnvironmentId }, repoId) .then((result) => { if (!stale) { setDefaultBaseRef(result.defaultBaseRef) @@ -88,7 +95,7 @@ export function CreateFromPicker({ return () => { stale = true } - }, [repoId]) + }, [activeRuntimeEnvironmentId, repoId]) React.useEffect(() => { setQuery('') @@ -107,8 +114,7 @@ export function CreateFromPicker({ let stale = false setIsSearching(true) const timer = window.setTimeout(() => { - void window.api.repos - .searchBaseRefs({ repoId, query: trimmedQuery, limit: 30 }) + void searchRuntimeRepoBaseRefs({ activeRuntimeEnvironmentId }, repoId, trimmedQuery, 30) .then((results) => { if (!stale) { setSearchResults(results) @@ -130,7 +136,7 @@ export function CreateFromPicker({ stale = true window.clearTimeout(timer) } - }, [open, query, repoId]) + }, [activeRuntimeEnvironmentId, open, query, repoId]) return (
diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index d05187fc95f..da03b76a964 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -79,6 +79,19 @@ import { ORCA_BROWSER_FOCUS_REQUEST_EVENT, type BrowserFocusRequestDetail } from './browser-focus' +import { + isRemoteRuntimeFileOperation, + statRuntimePath, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { + BrowserBackResult, + BrowserGotoResult, + BrowserReloadResult, + BrowserScreenshotResult, + BrowserTabInfo +} from '../../../../shared/runtime-types' import { formatByteCount, formatDownloadFinishedNotice, @@ -158,6 +171,31 @@ function getNotebookPathFromBrowserUrl(url: string): string | null { return filePath?.toLowerCase().endsWith('.ipynb') ? filePath : null } +function getRemoteBrowserKeypressKey(event: React.KeyboardEvent): string | null { + if (event.key.length === 1) { + return null + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return null + } + const supported = new Set([ + 'Enter', + 'Backspace', + 'Delete', + 'Tab', + 'Escape', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'PageUp', + 'PageDown' + ]) + return supported.has(event.key) ? event.key : null +} + function getLoadErrorMetadata(loadError: BrowserLoadError | null): { displayUrl: string host: string | null @@ -278,12 +316,39 @@ export default function BrowserPane({ browserTab: BrowserWorkspaceState isActive: boolean }): React.JSX.Element { + const activeRuntimeEnvironmentId = useAppStore( + (s) => s.settings?.activeRuntimeEnvironmentId ?? null + ) const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace) const browserPages = browserPagesByWorkspace[browserTab.id] ?? EMPTY_BROWSER_PAGES const activeBrowserPage = browserPages.find((page) => page.id === browserTab.activePageId) ?? browserPages[0] ?? null const updateBrowserPageState = useAppStore((s) => s.updateBrowserPageState) const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl) + const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim()) + + useEffect(() => { + if (!runtimeEnvironmentActive) { + return + } + for (const page of browserPages) { + destroyPersistentWebview(page.id) + } + }, [browserPages, runtimeEnvironmentActive]) + + if (runtimeEnvironmentActive) { + return activeBrowserPage ? ( + + ) : ( +
+ ) + } return (
@@ -304,6 +369,451 @@ export default function BrowserPane({ ) } +function RemoteBrowserPagePane({ + browserTab, + worktreeId, + isActive, + onUpdatePageState, + onSetUrl +}: { + browserTab: BrowserPageState + worktreeId: string + isActive: boolean + onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void + onSetUrl: (tabId: string, url: string) => void +}): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const addressBarInputRef = useRef(null) + const imageRef = useRef(null) + const [addressBarValue, setAddressBarValue] = useState(toDisplayUrl(browserTab.url)) + const [screenshotUrl, setScreenshotUrl] = useState(null) + const [remoteError, setRemoteError] = useState(null) + const [busy, setBusy] = useState(false) + const remotePageIdRef = useRef(null) + const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() ?? null + const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) + + useEffect(() => { + if (document.activeElement === addressBarInputRef.current) { + return + } + setAddressBarValue(toDisplayUrl(browserTab.url)) + }, [browserTab.url]) + + const runtimeTarget = useCallback(() => { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? target : null + }, [settings]) + + useEffect(() => { + if (!activeRuntimeEnvironmentId) { + return + } + return () => { + const remotePageId = remotePageIdRef.current + if (!remotePageId) { + return + } + const state = useAppStore.getState() + const currentEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() ?? null + const pageStillExists = Object.values(state.browserPagesByWorkspace).some((pages) => + pages.some((page) => page.id === browserTab.id) + ) + if (currentEnvironmentId === activeRuntimeEnvironmentId && pageStillExists) { + return + } + const removedHandle = state.removeRemoteBrowserPageHandle(browserTab.id, remotePageId) + remotePageIdRef.current = null + if (!removedHandle) { + return + } + // Why: remote browser tabs outlive React components on the daemon. Close + // only when the local page is gone or its owning runtime environment is. + void callRuntimeRpc( + { kind: 'environment', environmentId: removedHandle.environmentId }, + 'browser.tabClose', + { worktree: `id:${worktreeId}`, page: removedHandle.remotePageId }, + { timeoutMs: 15_000 } + ).catch(() => {}) + } + }, [activeRuntimeEnvironmentId, browserTab.id, worktreeId]) + + const applyRemoteTabInfo = useCallback( + (tab: Pick): void => { + const safeUrl = redactKagiSessionToken(tab.url || 'about:blank') + onSetUrl(browserTab.id, safeUrl) + onUpdatePageState(browserTab.id, { + title: getBrowserDisplayTitle(tab.title, safeUrl), + loading: false, + loadError: null + }) + setAddressBarValue(toDisplayUrl(safeUrl)) + }, + [browserTab.id, onSetUrl, onUpdatePageState] + ) + + const captureRemoteScreenshot = useCallback( + async (pageId: string): Promise => { + const target = runtimeTarget() + if (!target) { + return + } + const screenshot = await callRuntimeRpc( + target, + 'browser.screenshot', + { worktree: `id:${worktreeId}`, page: pageId, format: 'png' }, + { timeoutMs: 30_000 } + ) + setScreenshotUrl(`data:image/${screenshot.format};base64,${screenshot.data}`) + }, + [runtimeTarget, worktreeId] + ) + + const ensureRemotePage = useCallback(async (): Promise => { + const target = runtimeTarget() + if (!target) { + return null + } + const existingHandle = useAppStore.getState().remoteBrowserPageHandlesByPageId[browserTab.id] + if (existingHandle?.environmentId === target.environmentId) { + remotePageIdRef.current = existingHandle.remotePageId + return existingHandle.remotePageId + } + const initialUrl = + browserTab.url === ORCA_BROWSER_BLANK_URL ? 'about:blank' : browserTab.url || 'about:blank' + const created = await callRuntimeRpc<{ browserPageId: string }>( + target, + 'browser.tabCreate', + { worktree: `id:${worktreeId}`, url: initialUrl }, + { timeoutMs: 30_000 } + ) + remotePageIdRef.current = created.browserPageId + setRemoteBrowserPageHandle(browserTab.id, { + environmentId: target.environmentId, + remotePageId: created.browserPageId + }) + return created.browserPageId + }, [browserTab.id, browserTab.url, runtimeTarget, setRemoteBrowserPageHandle, worktreeId]) + + const refreshRemoteView = useCallback( + async (pageId?: string): Promise => { + const target = runtimeTarget() + const targetPageId = pageId ?? remotePageIdRef.current + if (!target || !targetPageId) { + return + } + const shown = await callRuntimeRpc<{ tab: BrowserTabInfo }>( + target, + 'browser.tabShow', + { worktree: `id:${worktreeId}`, page: targetPageId }, + { timeoutMs: 15_000 } + ) + applyRemoteTabInfo(shown.tab) + await captureRemoteScreenshot(targetPageId) + }, + [applyRemoteTabInfo, captureRemoteScreenshot, runtimeTarget, worktreeId] + ) + + useEffect(() => { + if (!isActive) { + return + } + let cancelled = false + setBusy(true) + setRemoteError(null) + void ensureRemotePage() + .then(async (pageId) => { + if (!pageId || cancelled) { + return + } + await refreshRemoteView(pageId) + }) + .catch((error: unknown) => { + if (!cancelled) { + setRemoteError(error instanceof Error ? error.message : 'Failed to open remote browser.') + } + }) + .finally(() => { + if (!cancelled) { + setBusy(false) + } + }) + return () => { + cancelled = true + } + }, [ensureRemotePage, isActive, refreshRemoteView]) + + useEffect(() => { + if (!isActive) { + return + } + return window.api.ui.onFocusBrowserAddressBar(() => { + addressBarInputRef.current?.focus() + addressBarInputRef.current?.select() + }) + }, [isActive]) + + const runRemoteNavigation = useCallback( + async ( + method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload', + url?: string + ) => { + const target = runtimeTarget() + if (!target) { + return + } + const pageId = await ensureRemotePage() + if (!pageId) { + return + } + setBusy(true) + setRemoteError(null) + onUpdatePageState(browserTab.id, { loading: true, loadError: null }) + try { + const params = + method === 'browser.goto' + ? { worktree: `id:${worktreeId}`, page: pageId, url: url ?? 'about:blank' } + : { worktree: `id:${worktreeId}`, page: pageId } + const result = await callRuntimeRpc< + BrowserGotoResult | BrowserBackResult | BrowserReloadResult + >(target, method, params, { timeoutMs: 30_000 }) + applyRemoteTabInfo(result) + await captureRemoteScreenshot(pageId) + } catch (error) { + const message = error instanceof Error ? error.message : 'Remote browser command failed.' + setRemoteError(message) + onUpdatePageState(browserTab.id, { + loading: false, + loadError: { code: 0, description: message, validatedUrl: url ?? browserTab.url } + }) + } finally { + setBusy(false) + } + }, + [ + applyRemoteTabInfo, + browserTab.id, + browserTab.url, + captureRemoteScreenshot, + ensureRemotePage, + onUpdatePageState, + runtimeTarget, + worktreeId + ] + ) + + const navigateToUrl = useCallback( + (url: string): void => { + void runRemoteNavigation('browser.goto', url) + }, + [runRemoteNavigation] + ) + + const submitAddressBar = (): void => { + const searchEngine = useAppStore.getState().browserDefaultSearchEngine + const kagiSessionLink = useAppStore.getState().browserKagiSessionLink + const nextUrl = normalizeBrowserNavigationUrl(addressBarValue, searchEngine, { + kagiSessionLink + }) + if (!nextUrl) { + const message = 'Enter a valid http(s) or localhost URL.' + setRemoteError(message) + onUpdatePageState(browserTab.id, { + loadError: { + code: 0, + description: message, + validatedUrl: redactKagiSessionToken(addressBarValue.trim()) || 'about:blank' + } + }) + return + } + navigateToUrl(nextUrl) + } + + const clickRemoteScreenshot = (event: React.MouseEvent): void => { + const target = runtimeTarget() + const pageId = remotePageIdRef.current + const image = imageRef.current + if (!target || !pageId || !image) { + return + } + const rect = image.getBoundingClientRect() + image.focus() + const scaleX = image.naturalWidth / rect.width + const scaleY = image.naturalHeight / rect.height + const x = Math.round((event.clientX - rect.left) * scaleX) + const y = Math.round((event.clientY - rect.top) * scaleY) + setBusy(true) + setRemoteError(null) + void (async () => { + try { + const params = { worktree: `id:${worktreeId}`, page: pageId } + await callRuntimeRpc( + target, + 'browser.mouseMove', + { ...params, x, y }, + { timeoutMs: 15_000 } + ) + await callRuntimeRpc(target, 'browser.mouseDown', params, { timeoutMs: 15_000 }) + await callRuntimeRpc(target, 'browser.mouseUp', params, { timeoutMs: 15_000 }) + await new Promise((resolve) => window.setTimeout(resolve, 300)) + await refreshRemoteView(pageId) + } catch (error) { + setRemoteError(error instanceof Error ? error.message : 'Remote click failed.') + } finally { + setBusy(false) + } + })() + } + + const handleRemoteScreenshotKeyDown = (event: React.KeyboardEvent): void => { + if (isEditableKeyboardTarget(event.target)) { + return + } + const target = runtimeTarget() + const pageId = remotePageIdRef.current + if (!target || !pageId) { + return + } + const params = { worktree: `id:${worktreeId}`, page: pageId } + const text = + event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey ? event.key : null + const key = text ? null : getRemoteBrowserKeypressKey(event) + if (!text && !key) { + return + } + event.preventDefault() + setRemoteError(null) + void (async () => { + try { + if (text) { + await callRuntimeRpc( + target, + 'browser.keyboardInsertText', + { ...params, text }, + { timeoutMs: 15_000 } + ) + } else if (key) { + await callRuntimeRpc( + target, + 'browser.keypress', + { ...params, key }, + { timeoutMs: 15_000 } + ) + } + await new Promise((resolve) => window.setTimeout(resolve, 100)) + await refreshRemoteView(pageId) + } catch (error) { + setRemoteError(error instanceof Error ? error.message : 'Remote keyboard input failed.') + } + })() + } + + const handleRemoteScreenshotWheel = (event: React.WheelEvent): void => { + const target = runtimeTarget() + const pageId = remotePageIdRef.current + if (!target || !pageId) { + return + } + event.preventDefault() + setRemoteError(null) + void callRuntimeRpc( + target, + 'browser.mouseWheel', + { + worktree: `id:${worktreeId}`, + page: pageId, + dx: Math.round(event.deltaX), + dy: Math.round(event.deltaY) + }, + { timeoutMs: 15_000 } + ) + .then(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 100)) + await refreshRemoteView(pageId) + }) + .catch((error: unknown) => { + setRemoteError(error instanceof Error ? error.message : 'Remote scroll failed.') + }) + } + + return ( +
+
+ + + + +
+
+ {screenshotUrl ? ( + + ) : ( +
+ {busy ? ( + + ) : ( + + )} +
+ {busy ? 'Opening remote browser' : 'Remote browser'} +
+
+ This pane is rendered from the active runtime server. +
+
+ )} + {remoteError ? ( +
+ {remoteError} +
+ ) : null} +
+
+ ) +} + function BrowserPagePane({ browserTab, workspaceId, @@ -1563,14 +2073,22 @@ function BrowserPagePane({ } try { - await window.api.fs.authorizeExternalPath({ targetPath: notebookPath }) - const stat = await window.api.fs.stat({ filePath: notebookPath }) + const activeWorktree = store.allWorktrees().find((w) => w.id === worktreeId) + const fileContext: RuntimeFileOperationArgs = { + settings: store.settings, + worktreeId, + worktreePath: activeWorktree?.path, + connectionId: undefined + } + if (!isRemoteRuntimeFileOperation(fileContext, notebookPath)) { + await window.api.fs.authorizeExternalPath({ targetPath: notebookPath }) + } + const stat = await statRuntimePath(fileContext, notebookPath) if (stat.isDirectory) { navigateBrowserUrl(url) return } - const activeWorktree = store.allWorktrees().find((w) => w.id === worktreeId) let relativePath = notebookPath if (activeWorktree?.path && isPathInsideWorktree(notebookPath, activeWorktree.path)) { relativePath = diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 83d4b6181ea..b444d713d54 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -9,6 +9,10 @@ import { useAppStore } from '@/store' import { joinPath } from '@/lib/path' import { setWithLRU } from '@/lib/scroll-cache' import { getConnectionId } from '@/lib/connection-context' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { writeRuntimeFile } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { getRuntimeGitBranchDiff, getRuntimeGitDiff } from '@/runtime/runtime-git-client' import '@/lib/monaco-setup' import { Button } from '@/components/ui/button' import type { OpenFile } from '@/store/slices/editor' @@ -207,26 +211,40 @@ export default function CombinedDiffViewer({ let result: GitDiffResult try { const connectionId = getConnectionId(file.worktreeId) ?? undefined + const state = useAppStore.getState() + const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) result = isBranchMode && branchCompare - ? ((await window.api.git.branchDiff({ - worktreePath: file.filePath, - compare: { - baseRef: branchCompare.baseRef, - baseOid: branchCompare.baseOid!, - headOid: branchCompare.headOid!, - mergeBase: branchCompare.mergeBase! + ? ((await getRuntimeGitBranchDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath: file.filePath, + connectionId }, - filePath: entry.path, - oldPath: entry.oldPath, - connectionId - })) as GitDiffResult) - : ((await window.api.git.diff({ - worktreePath: file.filePath, - filePath: entry.path, - staged: 'area' in entry && entry.area === 'staged', - connectionId - })) as GitDiffResult) + { + compare: { + baseRef: branchCompare.baseRef, + baseOid: branchCompare.baseOid!, + headOid: branchCompare.headOid!, + mergeBase: branchCompare.mergeBase! + }, + filePath: entry.path, + oldPath: entry.oldPath + } + )) as GitDiffResult) + : ((await getRuntimeGitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath: file.filePath, + connectionId + }, + { + filePath: entry.path, + staged: 'area' in entry && entry.area === 'staged' + } + )) as GitDiffResult) } catch { result = { kind: 'text', @@ -261,6 +279,7 @@ export default function CombinedDiffViewer({ branchCompare?.mergeBase, branchEntries, file.filePath, + file.runtimeEnvironmentId, isBranchMode, uncommittedEntries ] @@ -287,7 +306,20 @@ export default function CombinedDiffViewer({ const absolutePath = joinPath(file.filePath, section.path) try { const connectionId = getConnectionId(file.worktreeId) ?? undefined - await window.api.fs.writeFile({ filePath: absolutePath, content, connectionId }) + const state = useAppStore.getState() + const worktree = file.worktreeId + ? findWorktreeById(state.worktreesByRepo, file.worktreeId) + : null + await writeRuntimeFile( + { + settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), + worktreeId: file.worktreeId, + worktreePath: worktree?.path ?? null, + connectionId + }, + absolutePath, + content + ) setSections((prev) => prev.map((s, i) => (i === index ? { ...s, modifiedContent: content, dirty: false } : s)) ) @@ -295,7 +327,7 @@ export default function CombinedDiffViewer({ console.error('Save failed:', err) } }, - [file.filePath, file.worktreeId, sections] + [file.filePath, file.runtimeEnvironmentId, file.worktreeId, sections] ) const handleSectionSaveRef = useRef(handleSectionSave) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 57ddc16864f..872e43e1766 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -237,6 +237,7 @@ export function EditorContent({ content={editorContent} filePath={activeFile.filePath} worktreeId={activeFile.worktreeId} + runtimeEnvironmentId={activeFile.runtimeEnvironmentId} scrollCacheKey={`${editorViewStateKey}:rich`} onContentChange={onContentChangeWithFm} onDirtyStateHint={handleDirtyStateHint} diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index f7067db3144..3be4b61f5aa 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -38,6 +38,7 @@ import EditorViewToggle, { } from './EditorViewToggle' import { EditorContent } from './EditorContent' import { scrollTopCache, cursorPositionCache, diffViewStateCache } from '@/lib/scroll-cache' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import type { GitDiffResult } from '../../../../shared/types' import { getOpenFilesForExternalFileChange, @@ -60,6 +61,19 @@ import { isMarkdownPreviewShortcut } from './markdown-preview-controls' import type { EditorToggleValue } from './EditorViewToggle' +import { + createRuntimePath, + getRuntimeFileReadScope, + readRuntimeFileContent, + renameRuntimePath, + runtimePathExists +} from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + getRuntimeGitBranchDiff, + getRuntimeGitDiff, + getRuntimeGitScope +} from '@/runtime/runtime-git-client' const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') @@ -199,6 +213,7 @@ function EditorPanelInner({ const activeFileWorktreeId = activeFile?.worktreeId ?? null const activeFileMode = activeFile?.mode ?? null const activeFileDiffSource = activeFile?.diffSource + const activeFileRuntimeEnvironmentId = activeFile?.runtimeEnvironmentId const activeViewStateId = activeViewStateIdProp ?? activeFileId const [fileContents, setFileContents] = useState>({}) const [diffContents, setDiffContents] = useState>({}) @@ -389,18 +404,26 @@ function EditorPanelInner({ try { const connectionId = getConnectionId(worktreeId ?? null) ?? undefined const restoredOpenFile = openFilesRef.current.find((file) => file.id === id) - if ( - !connectionId && - restoredOpenFile?.filePath === filePath && - restoredOpenFile.relativePath === filePath - ) { + const activeSettings = useAppStore.getState().settings + const readSettings = settingsForRuntimeOwner( + activeSettings, + restoredOpenFile?.runtimeEnvironmentId + ) + if (restoredOpenFile?.filePath === filePath && restoredOpenFile.relativePath === filePath) { + if (readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId) { + // Why: restored external-file tabs contain client-local absolute + // paths. Remote runtime and SSH workspaces cannot read those paths + // without an explicit upload/import flow. + throw new Error('External local files are not available for remote workspaces.') + } // Why: external files selected through OS/browser/drop flows are // authorized in the main process, but that grant is in-memory. On // session restore, re-authorize only tabs that were stored with an // absolute relativePath because they came from outside a worktree. await window.api.fs.authorizeExternalPath({ targetPath: filePath }) } - const key = inFlightReadKey(connectionId, filePath) + const readScope = getRuntimeFileReadScope(readSettings, connectionId) + const key = inFlightReadKey(readScope, filePath) // Why: share the IPC round-trip across split-pane EditorPanels viewing // the same file. The first caller starts the read and registers the // promise; concurrent callers (triggered by the same external-change @@ -408,7 +431,13 @@ function EditorPanelInner({ // downstream setContent transactions. let pending = inFlightFileReads.get(key) if (!pending) { - pending = window.api.fs.readFile({ filePath, connectionId }) as Promise + pending = readRuntimeFileContent({ + settings: readSettings, + filePath, + relativePath: restoredOpenFile?.relativePath, + worktreeId, + connectionId + }) as Promise inFlightFileReads.set(key, pending) // Why: limit deduplication to synchronous callers (like N split panes // responding to the exact same event loop dispatch). Caching the promise @@ -468,6 +497,9 @@ function EditorPanelInner({ ? file.branchCompare : null const connectionId = getConnectionId(file.worktreeId) ?? undefined + const activeSettings = useAppStore.getState().settings + const fileSettings = settingsForRuntimeOwner(activeSettings, file.runtimeEnvironmentId) + const gitScope = getRuntimeGitScope(fileSettings, connectionId) // Why: Changes view mode runs on top of an edit-mode tab and asks git // for an unstaged diff against HEAD for that file. Use the 'unstaged' // diff-source key so multiple Changes tabs across split panes share one @@ -479,7 +511,7 @@ function EditorPanelInner({ const compareAgainstHead = file.mode === 'edit' const key = inFlightDiffKey( { ...file, diffSource: effectiveDiffSource }, - connectionId, + gitScope, compareAgainstHead ) // Why: same rationale as inFlightFileReads above — a single external @@ -490,25 +522,37 @@ function EditorPanelInner({ if (!pending) { pending = ( effectiveDiffSource === 'branch' && branchCompare - ? window.api.git.branchDiff({ - worktreePath, - compare: { - baseRef: branchCompare.baseRef, - baseOid: branchCompare.baseOid!, - headOid: branchCompare.headOid!, - mergeBase: branchCompare.mergeBase! + ? getRuntimeGitBranchDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath, + connectionId }, - filePath: file.relativePath, - oldPath: file.branchOldPath, - connectionId - }) - : window.api.git.diff({ - worktreePath, - filePath: file.relativePath, - staged: effectiveDiffSource === 'staged', - compareAgainstHead, - connectionId - }) + { + compare: { + baseRef: branchCompare.baseRef, + baseOid: branchCompare.baseOid!, + headOid: branchCompare.headOid!, + mergeBase: branchCompare.mergeBase! + }, + filePath: file.relativePath, + oldPath: file.branchOldPath + } + ) + : getRuntimeGitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath, + connectionId + }, + { + filePath: file.relativePath, + staged: effectiveDiffSource === 'staged', + compareAgainstHead + } + ) ) as Promise inFlightDiffReads.set(key, pending) queueMicrotask(() => { @@ -885,10 +929,20 @@ function EditorPanelInner({ oldPath.length - renameDialogFile.relativePath.length - 1 ) const newPath = `${worktreeRoot}/${newRelPath}` + const connectionId = getConnectionId(renameDialogFile.worktreeId) ?? undefined + const fileContext = { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + renameDialogFile.runtimeEnvironmentId + ), + worktreeId: renameDialogFile.worktreeId, + worktreePath: worktreeRoot, + connectionId + } // Prevent silently overwriting an existing file (but allow keeping // the current name — the file's own path is not a conflict). - if (newPath !== oldPath && (await window.api.shell.pathExists(newPath))) { + if (newPath !== oldPath && (await runtimePathExists(fileContext, newPath))) { setRenameError('A file with that name already exists') return } @@ -925,12 +979,12 @@ function EditorPanelInner({ // if the directory already exists (assertNotExists guard), so only call // it when the directory is not yet on disk. const newDir = newPath.slice(0, newPath.lastIndexOf('/')) - if (newDir !== worktreeRoot && !(await window.api.shell.pathExists(newDir))) { - await window.api.fs.createDir({ dirPath: newDir }) + if (newDir !== worktreeRoot && !(await runtimePathExists(fileContext, newDir))) { + await createRuntimePath(fileContext, newDir, 'directory') } try { - await window.api.fs.rename({ oldPath, newPath }) + await renameRuntimePath(fileContext, oldPath, newPath) } catch (err) { setRenameError(err instanceof Error ? err.message : 'Failed to rename file') return @@ -941,6 +995,7 @@ function EditorPanelInner({ filePath: newPath, relativePath: newRelPath, worktreeId: renameDialogFile.worktreeId, + runtimeEnvironmentId: renameDialogFile.runtimeEnvironmentId, language: detectLanguage(newRelPath), mode: 'edit' }) @@ -1008,6 +1063,7 @@ function EditorPanelInner({ filePath: activeFilePath, relativePath: activeFileRelativePath, worktreeId: activeFileWorktreeId, + runtimeEnvironmentId: activeFileRuntimeEnvironmentId, language: shortcutLanguage }) } @@ -1019,6 +1075,7 @@ function EditorPanelInner({ activeFileMode, activeFilePath, activeFileRelativePath, + activeFileRuntimeEnvironmentId, activeFileWorktreeId, openMarkdownPreview ]) @@ -1230,6 +1287,7 @@ function EditorPanelInner({ filePath: activeFile.filePath, relativePath: activeFile.relativePath, worktreeId: activeFile.worktreeId, + runtimeEnvironmentId: activeFile.runtimeEnvironmentId, language: resolvedLanguage }) } @@ -1242,6 +1300,17 @@ function EditorPanelInner({ {canShowMarkdownPreview && } { + if ( + isLocalPathOpenBlocked( + settingsForRuntimeOwner(settings, activeFile.runtimeEnvironmentId), + { + connectionId: getConnectionId(activeFile.worktreeId) + } + ) + ) { + showLocalPathOpenBlockedToast() + return + } window.api.shell.openPath(activeFile.filePath) }} > @@ -1419,6 +1488,13 @@ function EditorPanelInner({ ?.path ?? '') : '' } + disableBrowse={Boolean( + settingsForRuntimeOwner( + settings, + renameDialogFile?.runtimeEnvironmentId + )?.activeRuntimeEnvironmentId?.trim() || + (renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null) + )} externalError={renameError} onClose={() => { setRenameDialogFileId(null) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index bebadac8475..05ab3574c0e 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -21,6 +21,7 @@ import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' import { toast } from 'sonner' import { computeEditorFontSize } from '@/lib/editor-font-zoom' +import { getConnectionId } from '@/lib/connection-context' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import { detectLanguage } from '@/lib/language-detect' import type { MarkdownDocument, Worktree } from '../../../../shared/types' @@ -48,7 +49,9 @@ import { } from './markdown-preview-search' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' import { openHttpLink } from '@/lib/http-link-routing' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { markdownPreviewUrlTransform } from './markdown-preview-url-transform' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { buildMarkdownTableOfContents } from './markdown-table-of-contents' import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel' @@ -188,9 +191,25 @@ export default function MarkdownPreview({ const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode) const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const sourceRuntimeEnvironmentId = useAppStore( + (s) => s.openFiles.find((file) => file.filePath === filePath)?.runtimeEnvironmentId + ) const sourceWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, filePath) + const sourceConnectionId = sourceWorktree ? getConnectionId(sourceWorktree.id) : null const worktreeRoot = sourceWorktree?.path ?? null const settings = useAppStore((s) => s.settings) + const imageRuntimeContext = useMemo( + () => + sourceWorktree + ? { + settings: settingsForRuntimeOwner(settings, sourceRuntimeEnvironmentId), + worktreeId: sourceWorktree.id, + worktreePath: sourceWorktree.path, + connectionId: sourceConnectionId + } + : undefined, + [settings, sourceConnectionId, sourceRuntimeEnvironmentId, sourceWorktree] + ) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const editorFontSize = computeEditorFontSize(14, editorFontZoomLevel) const isDark = @@ -516,6 +535,20 @@ export default function MarkdownPreview({ return } if (parsed.protocol === 'file:') { + if ( + isLocalPathOpenBlocked( + settingsForRuntimeOwner( + useAppStore.getState().settings, + sourceRuntimeEnvironmentId + ), + { connectionId: sourceConnectionId } + ) + ) { + // Why: modifier-open delegates to the client OS. Server-local + // file:// targets from remote runtime/SSH worktrees cannot be opened locally. + showLocalPathOpenBlockedToast() + return + } const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot) if (classified?.kind === 'markdown') { // Why: use the classifier's stripped absolutePath (no `:line:col` @@ -565,10 +598,25 @@ export default function MarkdownPreview({ void activateMarkdownLink(href, { sourceFilePath: filePath, worktreeId: sourceWorktree.id, - worktreeRoot: sourceWorktree.path + worktreeRoot: sourceWorktree.path, + runtimeEnvironmentId: sourceRuntimeEnvironmentId }) return } + if ( + isLocalPathOpenBlocked( + settingsForRuntimeOwner( + useAppStore.getState().settings, + sourceRuntimeEnvironmentId + ), + { connectionId: sourceConnectionId } + ) + ) { + // Why: without a workspace match, opening a file URI delegates to + // the client OS. Remote runtime/SSH paths are not local files. + showLocalPathOpenBlockedToast() + return + } void window.api.shell.openFileUri(target.toString()) return } @@ -587,6 +635,7 @@ export default function MarkdownPreview({ filePath: absolutePath, relativePath, worktreeId: targetWorktree.id, + runtimeEnvironmentId: sourceRuntimeEnvironmentId, language, mode: 'edit' }) @@ -610,6 +659,7 @@ export default function MarkdownPreview({ filePath: absolutePath, relativePath, worktreeId: targetWorktree.id, + runtimeEnvironmentId: sourceRuntimeEnvironmentId, language }, { anchor: target.hash ? target.hash.slice(1) : null } @@ -621,6 +671,7 @@ export default function MarkdownPreview({ filePath: absolutePath, relativePath, worktreeId: targetWorktree.id, + runtimeEnvironmentId: sourceRuntimeEnvironmentId, language, mode: 'edit' }) @@ -642,7 +693,7 @@ export default function MarkdownPreview({ // eslint-disable-next-line react-hooks/rules-of-hooks -- react-markdown // instantiates component overrides as regular React components, so hooks // are valid here despite the lowercase function name. - const resolvedSrc = useLocalImageSrc(src, filePath) + const resolvedSrc = useLocalImageSrc(src, filePath, undefined, imageRuntimeContext) const handleImageClick = (event: React.MouseEvent): void => { if (!isMarkdownPreviewOpenModifier(event, isMac)) { return @@ -657,7 +708,8 @@ export default function MarkdownPreview({ void activateMarkdownLink(src, { sourceFilePath: filePath, worktreeId: sourceWorktree.id, - worktreeRoot: sourceWorktree.path + worktreeRoot: sourceWorktree.path, + runtimeEnvironmentId: sourceRuntimeEnvironmentId }) } @@ -752,6 +804,7 @@ export default function MarkdownPreview({ activateMarkdownLink, isDark, isMac, + imageRuntimeContext, markdownDocumentIndex, onOpenDocument, openFile, @@ -759,6 +812,8 @@ export default function MarkdownPreview({ scrollToAnchor, setMarkdownViewMode, setPendingEditorReveal, + sourceConnectionId, + sourceRuntimeEnvironmentId, sourceWorktree, worktreeRoot, worktreesByRepo diff --git a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx index 3d9def2b593..a702e9d6d5b 100644 --- a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx +++ b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx @@ -9,6 +9,7 @@ import { import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { getRuntimeGitRemoteFileUrl } from '@/runtime/runtime-git-client' type MonacoGutterContextMenuProps = { open: boolean @@ -60,12 +61,15 @@ export function MonacoGutterContextMenu({ return } const connectionId = getConnectionId(activeFile?.worktreeId ?? null) ?? undefined - const url = await window.api.git.remoteFileUrl({ - worktreePath: worktree.path, - relativePath, - line, - connectionId - }) + const url = await getRuntimeGitRemoteFileUrl( + { + settings: state.settings, + worktreeId: activeFile.worktreeId, + worktreePath: worktree.path, + connectionId + }, + { relativePath, line } + ) if (url) { window.api.ui.writeClipboardText(url) } diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 096ed5062f7..163b31c8bef 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -11,6 +11,7 @@ import { RichMarkdownToolbar } from './RichMarkdownToolbar' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import { useLocalImagePick } from './useLocalImagePick' import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { getConnectionId } from '@/lib/connection-context' import { slashCommands, syncDocLinkMenu, syncSlashMenu } from './rich-markdown-commands' import type { DocLinkMenuRow, @@ -35,7 +36,9 @@ import { normalizeSoftBreaks } from './rich-markdown-normalize' import { autoFocusRichEditor } from './rich-markdown-auto-focus' import { handleRichMarkdownCut } from './rich-markdown-cut-handler' import { openHttpLink } from '@/lib/http-link-routing' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { toast } from 'sonner' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation' import { absolutePathToFileUri as toFileUrlForOsEscape, @@ -54,6 +57,7 @@ type RichMarkdownEditorProps = { content: string filePath: string worktreeId: string + runtimeEnvironmentId?: string | null scrollCacheKey: string onContentChange: (content: string) => void onDirtyStateHint: (dirty: boolean) => void @@ -168,6 +172,7 @@ export default function RichMarkdownEditor({ content, filePath, worktreeId, + runtimeEnvironmentId, scrollCacheKey, onContentChange, onDirtyStateHint, @@ -179,6 +184,7 @@ export default function RichMarkdownEditor({ headerSlot }: RichMarkdownEditorProps): React.JSX.Element { const rootRef = useRef(null) + const settings = useAppStore((s) => s.settings) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink) const worktreeRoot = useAppStore((s) => { @@ -341,7 +347,12 @@ export default function RichMarkdownEditor({ if (!src) { return false } - void activateMarkdownLink(src, { sourceFilePath: filePath, worktreeId, worktreeRoot }) + void activateMarkdownLink(src, { + sourceFilePath: filePath, + worktreeId, + worktreeRoot, + runtimeEnvironmentId + }) return true } if (clickedNode?.type.name === 'markdownDocLink') { @@ -370,7 +381,20 @@ export default function RichMarkdownEditor({ } if (classified.kind === 'external') { openHttpLink(classified.url, { forceSystemBrowser: true }) - } else if (classified.kind === 'markdown') { + return true + } + if ( + isLocalPathOpenBlocked( + settingsForRuntimeOwner(useAppStore.getState().settings, runtimeEnvironmentId), + { connectionId: getConnectionId(worktreeId) } + ) + ) { + // Why: Shift-click opens through the client OS. Server-local paths + // from remote runtime/SSH worktrees are not meaningful on this client. + showLocalPathOpenBlockedToast() + return true + } + if (classified.kind === 'markdown') { void window.api.shell.pathExists(classified.absolutePath).then((exists) => { if (!exists) { toast.error(`File not found: ${classified.relativePath}`) @@ -383,7 +407,12 @@ export default function RichMarkdownEditor({ } return true } - void activateMarkdownLink(href, { sourceFilePath: filePath, worktreeId, worktreeRoot }) + void activateMarkdownLink(href, { + sourceFilePath: filePath, + worktreeId, + worktreeRoot, + runtimeEnvironmentId + }) return true } }, @@ -497,22 +526,30 @@ export default function RichMarkdownEditor({ useModifierHeldClass(rootRef, isMac) - // Why: the custom Image extension reads filePath from editor.storage to resolve - // relative image src values to file:// URLs for display. After updating the - // stored path we dispatch a no-op transaction so ProseMirror re-renders image - // nodes with the new resolved src (renderHTML reads storage at render time). + // Why: the custom Image extension reads filePath/runtimeContext from storage + // to resolve relative image src values. After updating storage we dispatch a + // no-op transaction so ProseMirror re-renders image nodes with the new source. useEffect(() => { if (editor) { isApplyingProgrammaticUpdateRef.current = true try { // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(editor.storage as any).image.filePath = filePath + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(editor.storage as any).image.runtimeContext = worktreeRoot + ? { + settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), + worktreeId, + worktreePath: worktreeRoot, + connectionId: getConnectionId(worktreeId) + } + : undefined editor.view.dispatch(editor.state.tr) } finally { isApplyingProgrammaticUpdateRef.current = false } } - }, [editor, filePath]) + }, [editor, filePath, runtimeEnvironmentId, settings, worktreeId, worktreeRoot]) // Why: the doc link NodeView reads the document list from storage to style // resolved vs. missing links. The no-op transaction with meta flag triggers @@ -531,7 +568,7 @@ export default function RichMarkdownEditor({ } }, [editor, markdownDocuments]) - const handleLocalImagePick = useLocalImagePick(editor, filePath) + const handleLocalImagePick = useLocalImagePick(editor, filePath, worktreeId, runtimeEnvironmentId) useEffect(() => { handleLocalImagePickRef.current = handleLocalImagePick @@ -546,7 +583,8 @@ export default function RichMarkdownEditor({ } = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, { sourceFilePath: filePath, worktreeId, - worktreeRoot + worktreeRoot, + runtimeEnvironmentId }) useEffect(() => { diff --git a/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx b/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx index a015a011b39..de63bfdb4dd 100644 --- a/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx +++ b/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx @@ -16,6 +16,7 @@ type UntitledFileRenameDialogProps = { currentName: string worktreePath: string externalError?: string | null + disableBrowse?: boolean onClose: () => void onConfirm: (newRelativePath: string) => void } @@ -25,6 +26,7 @@ export function UntitledFileRenameDialog({ currentName, worktreePath, externalError, + disableBrowse = false, onClose, onConfirm }: UntitledFileRenameDialogProps): React.JSX.Element { @@ -143,8 +145,11 @@ export function UntitledFileRenameDialog({ variant="outline" size="icon" className="h-8 w-8 shrink-0" + disabled={disableBrowse} onClick={() => void handleBrowse()} - title="Browse folders" + title={ + disableBrowse ? 'Folder picker unavailable for remote files' : 'Browse folders' + } > diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index b36b5b472e9..6edf848c54e 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: autosave behavior depends on event wiring, + dirty drafts, remote routing, quiesce, and failure cleanup in one harness. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createStore, type StoreApi } from 'zustand/vanilla' import { createEditorSlice } from '@/store/slices/editor' @@ -7,6 +9,11 @@ import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosa import { attachEditorAutosaveController } from './editor-autosave-controller' import { registerPendingEditorFlush } from './editor-pending-flush' import { __clearSelfWriteRegistryForTests, hasRecentSelfWrite } from './editor-self-write-registry' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' type WindowStub = { addEventListener: Window['addEventListener'] @@ -18,6 +25,9 @@ type WindowStub = { fs: { writeFile: ReturnType } + runtimeEnvironments?: { + call: ReturnType + } } } @@ -107,6 +117,70 @@ describe('attachEditorAutosaveController', () => { } }) + it('saves remote files through the owning runtime environment', async () => { + clearRuntimeCompatibilityCacheForTests() + const writeFile = vi.fn().mockResolvedValue(undefined) + const runtimeCall = vi.fn().mockResolvedValue({ + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-env-1' } + }) + const runtimeTransportCall = vi.fn((args: RuntimeEnvironmentCallRequest) => { + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args, 'runtime-env-1') ?? runtimeCall(args) + ) + }) + const eventTarget = new EventTarget() + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { + fs: { writeFile }, + runtimeEnvironments: { call: runtimeTransportCall } + } + } satisfies WindowStub) + + const store = createEditorStore() + store.setState({ + settings: { + editorAutoSave: true, + editorAutoSaveDelayMs: 1000, + activeRuntimeEnvironmentId: 'env-2' + } as never, + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' }] as never + } + }) + store.getState().openFile({ + filePath: '/remote/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: 'env-1', + language: 'typescript', + mode: 'edit' + }) + store.getState().setEditorDraft('/remote/repo/file.ts', 'edited') + store.getState().markFileDirty('/remote/repo/file.ts', true) + + const cleanup = attachEditorAutosaveController(store) + try { + await requestDirtyFileSave() + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.write', + params: { worktree: 'wt-1', relativePath: 'file.ts', content: 'edited' }, + timeoutMs: 15_000 + }) + expect(writeFile).not.toHaveBeenCalled() + } finally { + cleanup() + } + }) + it('flushes mounted rich-editor changes before restart-driven dirty-file saves', async () => { const writeFile = vi.fn().mockResolvedValue(undefined) const eventTarget = new EventTarget() diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 1d94c6955ed..73c2817d7ca 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -1,7 +1,13 @@ +/* eslint-disable max-lines -- Why: autosave owns the save queue, quiesce +coordination, and dirty-file shutdown hooks; keeping those lifecycles together +avoids split-brain saves across visible and hidden editors. */ import type { StoreApi } from 'zustand' import type { AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { getConnectionId } from '@/lib/connection-context' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { writeRuntimeFile } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { canAutoSaveOpenFile, getOpenFilesForExternalFileChange, @@ -69,6 +75,9 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { const contentToSave = state.editorDrafts[file.id] ?? fallbackContent const connectionId = getConnectionId(liveFile.worktreeId) ?? undefined + const worktree = liveFile.worktreeId + ? findWorktreeById(state.worktreesByRepo ?? {}, liveFile.worktreeId) + : null // Why: stamp before the write so the fs:changed event that our own // write produces is ignored by useEditorExternalWatch instead of // round-tripping back into a setContent that jumps the cursor to the @@ -76,11 +85,16 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { // debounce window). See editor-self-write-registry. recordSelfWrite(liveFile.filePath) try { - await window.api.fs.writeFile({ - filePath: liveFile.filePath, - content: contentToSave, - connectionId - }) + await writeRuntimeFile( + { + settings: settingsForRuntimeOwner(state.settings, liveFile.runtimeEnvironmentId), + worktreeId: liveFile.worktreeId, + worktreePath: worktree?.path ?? null, + connectionId + }, + liveFile.filePath, + contentToSave + ) } catch (error) { // Why: the self-write stamp is only valid if a disk write actually // happened. Clearing it on failure keeps the external watcher from diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 4cc560b2636..89e8478d134 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -14,6 +14,7 @@ import { BlockMath, InlineMath } from '@tiptap/extension-mathematics' import { Markdown } from '@tiptap/markdown' import { createLowlight, common } from 'lowlight' import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html' import { MarkdownDocLink } from './rich-markdown-doc-link' import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock' @@ -56,7 +57,7 @@ export function createRichMarkdownExtensions({ // and works identically in dev and production modes. Image.extend({ addStorage() { - return { filePath: '' } + return { filePath: '', runtimeContext: undefined as RuntimeFileOperationArgs | undefined } }, addNodeView() { return ({ node, HTMLAttributes }) => { @@ -80,11 +81,14 @@ export function createRichMarkdownExtensions({ const loadImage = (src: string | undefined): void => { const fp = this.storage.filePath as string + const runtimeContext = this.storage.runtimeContext as + | RuntimeFileOperationArgs + | undefined if (src && fp) { // Why: when IPC resolution fails (e.g. unsupported format), // the ternary falls back to the raw src so the browser can // attempt its own loading rather than leaving a broken image. - void loadLocalImageSrc(src, fp).then((resolved) => { + void loadLocalImageSrc(src, fp, undefined, runtimeContext).then((resolved) => { img.src = resolved ? resolved : src }) } else if (src) { diff --git a/src/renderer/src/components/editor/useLinkBubble.ts b/src/renderer/src/components/editor/useLinkBubble.ts index a91b199b110..76941e0dc43 100644 --- a/src/renderer/src/components/editor/useLinkBubble.ts +++ b/src/renderer/src/components/editor/useLinkBubble.ts @@ -20,6 +20,7 @@ export function useLinkBubble( sourceFilePath: string worktreeId: string worktreeRoot: string | null + runtimeEnvironmentId?: string | null } ): { handleLinkSave: (href: string) => void @@ -108,7 +109,8 @@ export function useLinkBubble( void activateMarkdownLink(linkBubble.href, { sourceFilePath: linkContext.sourceFilePath, worktreeId: linkContext.worktreeId, - worktreeRoot: linkContext.worktreeRoot + worktreeRoot: linkContext.worktreeRoot, + runtimeEnvironmentId: linkContext.runtimeEnvironmentId }) }, [ activateMarkdownLink, @@ -116,6 +118,7 @@ export function useLinkBubble( linkContext.sourceFilePath, linkContext.worktreeId, linkContext.worktreeRoot, + linkContext.runtimeEnvironmentId, rootRef ]) diff --git a/src/renderer/src/components/editor/useLocalImagePick.ts b/src/renderer/src/components/editor/useLocalImagePick.ts index 8c1446ac236..444a849ca1d 100644 --- a/src/renderer/src/components/editor/useLocalImagePick.ts +++ b/src/renderer/src/components/editor/useLocalImagePick.ts @@ -2,8 +2,18 @@ import { useCallback } from 'react' import { toast } from 'sonner' import type { Editor } from '@tiptap/react' import { extractIpcErrorMessage, getImageCopyDestination } from './rich-markdown-image-utils' +import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' +import { basename, dirname } from '@/lib/path' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' -export function useLocalImagePick(editor: Editor | null, filePath: string): () => Promise { +export function useLocalImagePick( + editor: Editor | null, + filePath: string, + worktreeId: string | null, + runtimeEnvironmentId?: string | null +): () => Promise { return useCallback(async () => { if (!editor) { return @@ -18,6 +28,45 @@ export function useLocalImagePick(editor: Editor | null, filePath: string): () = if (!srcPath) { return } + const connectionId = getConnectionId(worktreeId) ?? undefined + const settings = settingsForRuntimeOwner( + useAppStore.getState().settings, + runtimeEnvironmentId + ) + if (settings?.activeRuntimeEnvironmentId?.trim() || connectionId) { + const worktreePath = getWorktreePath(worktreeId) + if (settings?.activeRuntimeEnvironmentId?.trim() && !worktreePath) { + toast.error('Worktree path not available.') + return + } + // Why: picked images are client-local files while remote markdown lives + // on the server. Upload beside the markdown file before inserting the + // relative image path so preview/save works from any client. + const { results } = await importExternalPathsToRuntime( + { + settings, + worktreeId, + worktreePath, + connectionId + }, + [srcPath], + dirname(filePath) + ) + const imported = results.find((result) => result.status === 'imported') + if (!imported) { + toast.error('Failed to insert image.') + return + } + editor + .chain() + .focus() + .insertContentAt(insertPos, { + type: 'image', + attrs: { src: basename(imported.destPath) } + }) + .run() + return + } // Why: copy the image next to the markdown file and insert a relative path // so the markdown stays portable and doesn't bloat with base64 data. const { imageName, destPath } = await getImageCopyDestination(filePath, srcPath) @@ -35,5 +84,14 @@ export function useLocalImagePick(editor: Editor | null, filePath: string): () = } catch (err) { toast.error(extractIpcErrorMessage(err, 'Failed to insert image.')) } - }, [editor, filePath]) + }, [editor, filePath, runtimeEnvironmentId, worktreeId]) +} + +function getWorktreePath(worktreeId: string | null): string | null { + if (!worktreeId) { + return null + } + const state = useAppStore.getState() + const worktrees = Object.values(state.worktreesByRepo ?? {}).flat() + return worktrees.find((worktree) => worktree.id === worktreeId)?.path ?? null } diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts new file mode 100644 index 00000000000..736be5a7672 --- /dev/null +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { getLocalImageCacheKey } from './useLocalImageSrc' + +describe('getLocalImageCacheKey', () => { + it('scopes local markdown image cache entries by runtime owner', () => { + const localKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + const remoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + const otherRemoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { + settings: { activeRuntimeEnvironmentId: 'env-2' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + + expect(localKey).not.toBe(remoteKey) + expect(remoteKey).not.toBe(otherRemoteKey) + }) +}) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 3206e99b353..1f55756fba7 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react' import { resolveImageAbsolutePath } from './markdown-preview-links' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { readRuntimeFilePreview } from '@/runtime/runtime-file-client' // Why: the renderer is served from http://localhost in dev mode, so file:// // URLs in tags are blocked by cross-origin restrictions. Loading images @@ -9,6 +11,21 @@ import { resolveImageAbsolutePath } from './markdown-preview-links' const BLOB_URL_CACHE_MAX_SIZE = 100 const blobUrlCache = new Map() +export function getLocalImageCacheKey( + absolutePath: string, + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } +): string { + const runtimeEnvironmentId = + runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client' + return [ + runtimeEnvironmentId, + runtimeContext?.connectionId ?? connectionId ?? 'local', + runtimeContext?.worktreeId ?? 'unknown-worktree', + absolutePath + ].join('\0') +} + // Why: blob URLs hold references to in-memory Blob objects; without eviction // the cache grows without bound and leaks memory. We evict the oldest entry // (Map iteration order is insertion order) and revoke its blob URL so the @@ -97,7 +114,8 @@ function isExternalUrl(src: string): boolean { export function useLocalImageSrc( rawSrc: string | undefined, filePath: string, - connectionId?: string | null + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } ): string | undefined { const [generation, setGeneration] = useState(cacheGeneration) @@ -113,8 +131,11 @@ export function useLocalImageSrc( return rawSrc } const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) - if (absolutePath && blobUrlCache.has(absolutePath)) { - return blobUrlCache.get(absolutePath) + if (absolutePath) { + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + if (blobUrlCache.has(cacheKey)) { + return blobUrlCache.get(cacheKey) + } } return undefined }) @@ -136,21 +157,21 @@ export function useLocalImageSrc( return } - if (blobUrlCache.has(absolutePath)) { - setDisplaySrc(blobUrlCache.get(absolutePath)) + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + if (blobUrlCache.has(cacheKey)) { + setDisplaySrc(blobUrlCache.get(cacheKey)) return } let cancelled = false - window.api.fs - .readFile({ filePath: absolutePath, connectionId: connectionId ?? undefined }) + readImagePreview(absolutePath, connectionId, runtimeContext) .then((result) => { if (cancelled) { return } if (result.isBinary && result.content) { const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') - cacheBlobUrl(absolutePath, url) + cacheBlobUrl(cacheKey, url) setDisplaySrc(url) } else { // Why: if the file exists but is not binary (e.g. an SVG stored as @@ -168,7 +189,7 @@ export function useLocalImageSrc( return () => { cancelled = true } - }, [rawSrc, filePath, generation, connectionId]) + }, [rawSrc, filePath, generation, connectionId, runtimeContext]) return displaySrc } @@ -181,7 +202,8 @@ export function useLocalImageSrc( export async function loadLocalImageSrc( rawSrc: string, filePath: string, - connectionId?: string | null + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } ): Promise { if ( rawSrc.startsWith('http://') || @@ -197,19 +219,17 @@ export async function loadLocalImageSrc( return null } - const cached = blobUrlCache.get(absolutePath) + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const cached = blobUrlCache.get(cacheKey) if (cached) { return cached } try { - const result = await window.api.fs.readFile({ - filePath: absolutePath, - connectionId: connectionId ?? undefined - }) + const result = await readImagePreview(absolutePath, connectionId, runtimeContext) if (result.isBinary && result.content) { const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') - cacheBlobUrl(absolutePath, url) + cacheBlobUrl(cacheKey, url) return url } // Why: if the file is not binary (e.g. an SVG stored as text) or content @@ -222,3 +242,23 @@ export async function loadLocalImageSrc( return null } + +function readImagePreview( + absolutePath: string, + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } +) { + if (!runtimeContext) { + return window.api.fs.readFile({ + filePath: absolutePath, + connectionId: connectionId ?? undefined + }) + } + return readRuntimeFilePreview( + { + ...runtimeContext, + connectionId: runtimeContext.connectionId ?? connectionId ?? undefined + }, + absolutePath + ) +} diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index f0fdb092c7d..96a0c3ec682 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -3,6 +3,8 @@ import type { MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { getConnectionId } from '@/lib/connection-context' +import { listRuntimeMarkdownDocuments, statRuntimePath } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import { createMarkdownDocumentIndex, resolveMarkdownDocLink } from './markdown-doc-links' @@ -48,10 +50,18 @@ export function useMarkdownDocuments( const requestId = requestRef.current + 1 requestRef.current = requestId try { - const documents = await window.api.fs.listMarkdownDocuments({ - rootPath: worktreePath, - connectionId: connectionId ?? undefined - }) + const documents = await listRuntimeMarkdownDocuments( + { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + activeFile.runtimeEnvironmentId + ), + worktreeId, + worktreePath, + connectionId: connectionId ?? undefined + }, + worktreePath + ) if (requestRef.current !== requestId) { return } @@ -68,18 +78,26 @@ export function useMarkdownDocuments( })) } } - }, [connectionId, worktreeId, worktreePath]) + }, [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath]) const openMarkdownDocument = useCallback( async (document: MarkdownDocument): Promise => { - if (!worktreeId) { + if (!worktreeId || !worktreePath) { return } try { - const stats = await window.api.fs.stat({ - filePath: document.filePath, - connectionId: connectionId ?? undefined - }) + const stats = await statRuntimePath( + { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + activeFile.runtimeEnvironmentId + ), + worktreeId, + worktreePath, + connectionId: connectionId ?? undefined + }, + document.filePath + ) if (stats.isDirectory) { await refreshMarkdownDocuments() return @@ -94,10 +112,18 @@ export function useMarkdownDocuments( relativePath: document.relativePath, worktreeId, language: 'markdown', + runtimeEnvironmentId: activeFile.runtimeEnvironmentId, mode: 'edit' }) }, - [connectionId, openFile, refreshMarkdownDocuments, worktreeId] + [ + activeFile.runtimeEnvironmentId, + connectionId, + openFile, + refreshMarkdownDocuments, + worktreeId, + worktreePath + ] ) useEffect(() => { diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index b16d06ffaf6..a83df7ae613 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -15,6 +15,7 @@ import { } from '@/lib/orchestration-setup-state' import { notifyProjectNotesSelectionChanged } from '@/lib/open-project-notes-tab' import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request' +import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { TerminalTab } from '../../../../shared/types' @@ -213,10 +214,11 @@ export function FloatingTerminalPanel({ .find((candidate) => candidate.id === activeWorktreeId) const projectId = state.repos.find((candidate) => candidate.id === worktree?.repoId)?.id ?? worktree?.repoId + const settings = state.settings let label = 'Project Notes' if (noteId && projectId) { try { - const result = await window.api.notes.show({ + const result = await showRuntimeProjectNote(settings, { projectId, worktreeId: activeWorktreeId, note: noteId @@ -226,7 +228,7 @@ export function FloatingTerminalPanel({ label = 'Project Notes' } if (projectId) { - await window.api.notes.link({ + await linkRuntimeProjectNote(settings, { projectId, worktreeId: activeWorktreeId, note: noteId, diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index 4182e068d92..59ccf7ea2d2 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -9,14 +9,17 @@ import { TYPE_FIELD_DATA_TYPE } from './columns' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' -import type { GitHubAssignableUser } from '../../../../shared/types' +import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GitHubIssueType, GitHubProjectField, GitHubProjectFieldMutationValue, GitHubProjectLabel, GitHubProjectRow, - GitHubProjectUser + GitHubProjectUser, + ListIssueTypesBySlugResult } from '../../../../shared/github-project-types' type Props = { @@ -264,6 +267,7 @@ function IssueTypeCell({ const [open, setOpen] = useState(false) const [options, setOptions] = useState([]) const [loading, setLoading] = useState(false) + const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') React.useEffect(() => { @@ -272,8 +276,17 @@ function IssueTypeCell({ } let cancelled = false setLoading(true) - window.api.gh - .listIssueTypesBySlug({ owner, repo }) + const target = getActiveRuntimeTarget(settings) + const request = + target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.project.listIssueTypesBySlug', + { owner, repo }, + { timeoutMs: 30_000 } + ) + : window.api.gh.listIssueTypesBySlug({ owner, repo }) + request .then((res) => { if (cancelled) { return @@ -290,7 +303,7 @@ function IssueTypeCell({ return () => { cancelled = true } - }, [open, owner, repo]) + }, [open, owner, repo, settings]) const trigger = ( @@ -713,8 +726,7 @@ function AssigneesCell({ }): React.JSX.Element { const assignees = row.content.assignees const [open, setOpen] = useState(false) - const [options, setOptions] = useState([]) - const [loading, setLoading] = useState(false) + const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') @@ -732,37 +744,12 @@ function AssigneesCell({ [assignees] ) - // Why: only hit the slug-addressed user list when the popover actually - // opens — the assignable-users query can be expensive for large repos. - React.useEffect(() => { - if (!open || !owner || !repo) { - return - } - let cancelled = false - setLoading(true) - window.api.gh - .listAssignableUsersBySlug({ - owner, - repo, - seedLogins: seedKey ? seedKey.split(',') : [] - }) - .then((res) => { - if (cancelled) { - return - } - if (res.ok) { - setOptions(res.users) - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false) - } - }) - return () => { - cancelled = true - } - }, [open, owner, repo, seedKey]) + const metadata = useRepoAssigneesBySlug( + open ? owner : null, + open ? repo : null, + seedKey ? seedKey.split(',') : [], + settings + ) const labelContent = assignees.length === 0 ? null : assignees.map((u) => ) @@ -791,10 +778,10 @@ function AssigneesCell({ {!owner || !repo ? (
Row has no repo slug.
- ) : loading ? ( + ) : metadata.loading ? (
Loading…
) : ( - options.map((u) => { + metadata.data.map((u) => { const isOn = assignees.some((a) => a.login === u.login) return ( - {loading ? ( + {metadata.loading ? (
Loading…
) : ( - users.map((u) => { + metadata.data.map((u) => { const isOn = selected.includes(u.login) return ( - {loading ? ( + {metadata.loading ? (
Loading…
) : ( - options.map((name) => { + metadata.data.map((name) => { const isOn = selected.includes(name) return ( +
+ onServerPathChange(event.target.value)} + /> + + +
+ + ) : ( + + )}
{ e.preventDefault() onClone() @@ -71,21 +130,38 @@ export function RepoStep({
+ {runtimeActive && ( +
+ + onCloneDestinationChange(event.target.value)} + /> +
+ )}
Workspace - {workspaceDir} + + {runtimeActive ? 'Runtime server' : workspaceDir} +
- SSH? Set hosts up in Settings + {runtimeActive ? 'Server paths only' : 'SSH? Set hosts up in Settings'}
diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index df477c1eb3a..dc0c0bbd405 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -8,7 +8,7 @@ import { applyDocumentTheme } from '@/lib/document-theme' import { track } from '@/lib/telemetry' import { buildAgentPickedPayload } from './agent-picked-payload' import { isGitRepoKind } from '../../../../shared/repo-kind' -import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings, OnboardingState, Repo, TuiAgent } from '../../../../shared/types' import type { NotificationDraft } from './NotificationStep' import { DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION, @@ -19,6 +19,7 @@ import { } from './onboarding-feature-setup' import { STEPS, type StepNumber } from './use-onboarding-flow-types' import { persistStep, useCloseWith, usePersistCurrentStep } from './use-onboarding-flow-persistence' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' export { STEPS } from './use-onboarding-flow-types' export type { StepId, StepNumber } from './use-onboarding-flow-types' @@ -38,6 +39,7 @@ export function useOnboardingFlow( const pathFailureReason = useAppStore((s) => s.pathFailureReason) const fetchRepos = useAppStore((s) => s.fetchRepos) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const addRepoPath = useAppStore((s) => s.addRepoPath) const openModal = useAppStore((s) => s.openModal) const initialStep = Math.min(Math.max(onboarding.lastCompletedStep, 0), STEPS.length - 1) @@ -69,6 +71,8 @@ export function useOnboardingFlow( const [featureSetupTerminalSelection, setFeatureSetupTerminalSelection] = useState(null) const [cloneUrl, setCloneUrl] = useState('') + const [serverPath, setServerPath] = useState('') + const [cloneDestination, setCloneDestination] = useState('') const [busyLabel, setBusyLabel] = useState(null) const [error, setError] = useState(null) @@ -394,35 +398,62 @@ export function useOnboardingFlow( ] ) - const openFolder = useCallback(async () => { - // Why: re-entry guard — rapid Cmd+Enter must not launch duplicate pickers. - if (busyLabel !== null) { - return - } - setError(null) - track('onboarding_step4_path_clicked', { path: 'open_folder' }) - const path = await window.api.repos.pickFolder() - if (!path) { - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'cancelled' }) - return - } - setBusyLabel('Opening project…') - try { - let result = await window.api.repos.add({ path }) - if ('error' in result && result.error.includes('Not a valid git repository')) { - result = await window.api.repos.add({ path, kind: 'folder' }) + const openFolder = useCallback( + async (kind: 'git' | 'folder' = 'git') => { + // Why: re-entry guard — rapid Cmd+Enter must not launch duplicate pickers. + if (busyLabel !== null) { + return } - if ('error' in result) { - throw new Error(result.error) + setError(null) + if (settings?.activeRuntimeEnvironmentId?.trim()) { + const path = serverPath.trim() + if (!path) { + const message = 'Enter a server path.' + setError(message) + return + } + track('onboarding_step4_path_clicked', { path: 'open_folder' }) + setBusyLabel(kind === 'git' ? 'Opening project…' : 'Opening folder…') + try { + const repo = await addRepoPath(path, kind) + if (!repo) { + track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) + return + } + await completeRepo(repo.id, isGitRepoKind(repo), 'open_folder') + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) + } finally { + setBusyLabel(null) + } + return } - await completeRepo(result.repo.id, isGitRepoKind(result.repo), 'open_folder') - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - } finally { - setBusyLabel(null) - } - }, [busyLabel, completeRepo]) + track('onboarding_step4_path_clicked', { path: 'open_folder' }) + const path = await window.api.repos.pickFolder() + if (!path) { + track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'cancelled' }) + return + } + setBusyLabel('Opening project…') + try { + let result = await window.api.repos.add({ path }) + if ('error' in result && result.error.includes('Not a valid git repository')) { + result = await window.api.repos.add({ path, kind: 'folder' }) + } + if ('error' in result) { + throw new Error(result.error) + } + await completeRepo(result.repo.id, isGitRepoKind(result.repo), 'open_folder') + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) + } finally { + setBusyLabel(null) + } + }, + [addRepoPath, busyLabel, completeRepo, serverPath, settings?.activeRuntimeEnvironmentId] + ) const clone = useCallback(async () => { // Why: re-entry guard — prevents Enter spamming from triggering duplicate clones. @@ -435,12 +466,30 @@ export function useOnboardingFlow( } setError(null) track('onboarding_step4_path_clicked', { path: 'clone_url' }) + const target = getActiveRuntimeTarget(settings) + const destination = + target.kind === 'environment' ? cloneDestination.trim() : settings.workspaceDir + if (!destination) { + const message = 'Enter a server path for the clone destination.' + setError(message) + return + } setBusyLabel('Cloning repo…') try { - const repo = await window.api.repos.clone({ - url: trimmed, - destination: settings.workspaceDir - }) + const repo = + target.kind === 'environment' + ? ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.clone', + { url: trimmed, destination }, + { timeoutMs: 10 * 60_000 } + ) + ).repo + : await window.api.repos.clone({ + url: trimmed, + destination + }) await completeRepo(repo.id, true, 'clone_url') } catch (err) { setError(err instanceof Error ? err.message : String(err)) @@ -451,7 +500,7 @@ export function useOnboardingFlow( } finally { setBusyLabel(null) } - }, [busyLabel, cloneUrl, completeRepo, settings]) + }, [busyLabel, cloneDestination, cloneUrl, completeRepo, settings]) const skip = useCallback(async () => { if (busyLabel) { @@ -521,6 +570,10 @@ export function useOnboardingFlow( hasSelectedFeatureSetup, cloneUrl, setCloneUrl, + serverPath, + setServerPath, + cloneDestination, + setCloneDestination, busyLabel, error, detectedSet, diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index b401307d286..886af2ef3bc 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -17,6 +17,7 @@ import { } from './checks-panel-content' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' export default function ChecksPanel(): React.JSX.Element { const activeWorktree = useActiveWorktree() @@ -377,11 +378,20 @@ export default function ChecksPanel(): React.JSX.Element { } setTitleSaving(true) try { - const ok = await window.api.gh.updatePRTitle({ - repoPath: repo.path, - prNumber: pr.number, - title: titleDraft.trim() - }) + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const ok = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.updatePRTitle', + { repo: repo.id, prNumber: pr.number, title: titleDraft.trim() }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePRTitle({ + repoPath: repo.path, + prNumber: pr.number, + title: titleDraft.trim() + }) if (ok) { // Re-fetch PR to get updated title await fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 54a7e186f32..ecda45f3348 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -272,7 +272,7 @@ function FileExplorerInner(): React.JSX.Element { scrollRef }) - const handleDuplicate = useFileDuplicate({ worktreePath, refreshDir }) + const handleDuplicate = useFileDuplicate({ activeWorktreeId, worktreePath, refreshDir }) if (!worktreePath) { return ( diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index f0370bced7c..229ee3c070e 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -29,6 +29,7 @@ import type { GitFileStatus } from '../../../../shared/types' import { STATUS_LABELS } from './status-display' import type { TreeNode } from './file-explorer-types' import { useFileExplorerRowDrag } from './useFileExplorerRowDrag' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' const ORCA_PATH_MIME = 'text/x-orca-file-path' @@ -372,7 +373,26 @@ export function FileExplorerRow({ Open Markdown Preview )} - window.api.shell.openPath(node.path)}> + { + const state = useAppStore.getState() + const activeWorktree = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === activeWorktreeId) + const activeRepo = activeWorktree + ? state.repos.find((repo) => repo.id === activeWorktree.repoId) + : null + if ( + isLocalPathOpenBlocked(state.settings, { + connectionId: activeRepo?.connectionId ?? null + }) + ) { + showLocalPathOpenBlockedToast() + return + } + window.api.shell.openPath(node.path) + }} + > {revealLabel} diff --git a/src/renderer/src/components/right-sidebar/NotesPanel.tsx b/src/renderer/src/components/right-sidebar/NotesPanel.tsx index 6b934f7d0bb..b54ffc4d125 100644 --- a/src/renderer/src/components/right-sidebar/NotesPanel.tsx +++ b/src/renderer/src/components/right-sidebar/NotesPanel.tsx @@ -23,6 +23,11 @@ import { useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' import { getProjectNotesEntityId, openProjectNotesTab } from '@/lib/open-project-notes-tab' import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events' +import { + deleteRuntimeProjectNote, + listRuntimeProjectNotes, + renameRuntimeProjectNote +} from '@/runtime/runtime-notes-client' import type { NoteSummary } from '../../../../shared/notes-types' export default function NotesPanel(): React.JSX.Element { @@ -30,6 +35,7 @@ export default function NotesPanel(): React.JSX.Element { const repo = useRepoById(activeWorktree?.repoId ?? null) const projectId = repo?.id ?? activeWorktree?.repoId ?? null const worktreeId = activeWorktree?.id ?? null + const settings = useAppStore((s) => s.settings) const [notes, setNotes] = useState([]) const [loading, setLoading] = useState(false) @@ -49,14 +55,14 @@ export default function NotesPanel(): React.JSX.Element { setLoading(true) setError(null) try { - const result = await window.api.notes.list({ projectId, worktreeId, limit: 100 }) + const result = await listRuntimeProjectNotes(settings, { projectId, worktreeId, limit: 100 }) setNotes(result.notes) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setLoading(false) } - }, [projectId, worktreeId]) + }, [projectId, settings, worktreeId]) useEffect(() => { void refresh() @@ -106,7 +112,7 @@ export default function NotesPanel(): React.JSX.Element { return } try { - const result = await window.api.notes.rename({ + const result = await renameRuntimeProjectNote(settings, { projectId, worktreeId, note: note.id, @@ -126,7 +132,7 @@ export default function NotesPanel(): React.JSX.Element { toast.error(err instanceof Error ? err.message : `Failed to rename '${note.title}'.`) } }, - [projectId, refresh, renameValue, worktreeId] + [projectId, refresh, renameValue, settings, worktreeId] ) const cancelRename = useCallback(() => { @@ -140,7 +146,7 @@ export default function NotesPanel(): React.JSX.Element { } setDeleting(true) try { - await window.api.notes.delete({ projectId, worktreeId, note: deleteTarget.id }) + await deleteRuntimeProjectNote(settings, { projectId, worktreeId, note: deleteTarget.id }) const entityId = getProjectNotesEntityId(projectId, deleteTarget.id) const state = useAppStore.getState() const tabIdsToClose: string[] = [] @@ -162,7 +168,7 @@ export default function NotesPanel(): React.JSX.Element { } finally { setDeleting(false) } - }, [deleteTarget, deleting, projectId, refresh, worktreeId]) + }, [deleteTarget, deleting, projectId, refresh, settings, worktreeId]) const copyNotePath = useCallback(async (note: NoteSummary): Promise => { await navigator.clipboard.writeText(note.relativePath) diff --git a/src/renderer/src/components/right-sidebar/PRActions.tsx b/src/renderer/src/components/right-sidebar/PRActions.tsx index bcb51d55324..bb913938c25 100644 --- a/src/renderer/src/components/right-sidebar/PRActions.tsx +++ b/src/renderer/src/components/right-sidebar/PRActions.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' import type { PRInfo, Repo, Worktree } from '../../../../shared/types' import { runWorktreeDeleteWithToast } from '../sidebar/delete-worktree-flow' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' const MERGE_METHODS = ['squash', 'merge', 'rebase'] as const @@ -39,11 +40,20 @@ export default function PRActions({ setMergeError(null) setMergeMenuOpen(false) try { - const result = await window.api.gh.mergePR({ - repoPath: repo.path, - prNumber: pr.number, - method - }) + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: true } | { ok: false; error: string }>( + target, + 'github.mergePR', + { repo: repo.id, prNumber: pr.number, method }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.mergePR({ + repoPath: repo.path, + prNumber: pr.number, + method + }) if (!result.ok) { setMergeError(result.error) } else { @@ -55,7 +65,7 @@ export default function PRActions({ setMerging(false) } }, - [repo.path, pr.number, onRefreshPR] + [repo.id, repo.path, pr.number, onRefreshPR] ) useEffect(() => { diff --git a/src/renderer/src/components/right-sidebar/Search.tsx b/src/renderer/src/components/right-sidebar/Search.tsx index d85c5228a8c..c5c535317d0 100644 --- a/src/renderer/src/components/right-sidebar/Search.tsx +++ b/src/renderer/src/components/right-sidebar/Search.tsx @@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' import { getConnectionId } from '@/lib/connection-context' +import { searchRuntimeFiles } from '@/runtime/runtime-file-client' import type { SearchFileResult, SearchMatch } from '../../../../shared/types' import { buildSearchRows } from './search-rows' import { cancelRevealFrame, openMatchResult } from './search-match-open' @@ -173,20 +174,27 @@ export default function Search(): React.JSX.Element { try { const state = useAppStore.getState() const connectionId = getConnectionId(activeWorktreeId!) ?? undefined - const results = await window.api.fs.search({ - query: query.trim(), - rootPath: worktreePath, - connectionId, - caseSensitive: - state.fileSearchStateByWorktree[activeWorktreeId!]?.caseSensitive ?? false, - wholeWord: state.fileSearchStateByWorktree[activeWorktreeId!]?.wholeWord ?? false, - useRegex: state.fileSearchStateByWorktree[activeWorktreeId!]?.useRegex ?? false, - includePattern: - state.fileSearchStateByWorktree[activeWorktreeId!]?.includePattern || undefined, - excludePattern: - state.fileSearchStateByWorktree[activeWorktreeId!]?.excludePattern || undefined, - maxResults: SEARCH_MAX_RESULTS - }) + const results = await searchRuntimeFiles( + { + settings: state.settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + { + query: query.trim(), + rootPath: worktreePath, + caseSensitive: + state.fileSearchStateByWorktree[activeWorktreeId!]?.caseSensitive ?? false, + wholeWord: state.fileSearchStateByWorktree[activeWorktreeId!]?.wholeWord ?? false, + useRegex: state.fileSearchStateByWorktree[activeWorktreeId!]?.useRegex ?? false, + includePattern: + state.fileSearchStateByWorktree[activeWorktreeId!]?.includePattern || undefined, + excludePattern: + state.fileSearchStateByWorktree[activeWorktreeId!]?.excludePattern || undefined, + maxResults: SEARCH_MAX_RESULTS + } + ) if (latestSearchIdRef.current === searchId) { updateActiveSearchState({ results }) } diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 7047bf1e8d4..a3aeea40cce 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -30,6 +30,7 @@ import { } from 'lucide-react' import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { detectLanguage } from '@/lib/language-detect' import { basename, dirname, joinPath } from '@/lib/path' import { cn } from '@/lib/utils' @@ -92,6 +93,16 @@ import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { getConnectionId } from '@/lib/connection-context' +import { + bulkStageRuntimeGitPaths, + bulkUnstageRuntimeGitPaths, + commitRuntimeGit, + discardRuntimeGitPath, + getRuntimeGitBranchCompare, + stageRuntimeGitPath, + unstageRuntimeGitPath +} from '@/runtime/runtime-git-client' +import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import { PullRequestIcon } from './checks-panel-content' import type { DiffComment, @@ -233,6 +244,7 @@ function SourceControlInner(): React.JSX.Element { const remoteStatusesByWorktree = useAppStore((s) => s.remoteStatusesByWorktree) const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive) const inFlightRemoteOpKind = useAppStore((s) => s.inFlightRemoteOpKind) + const settings = useAppStore((s) => s.settings) const hostedReviewCache = useAppStore((s) => s.hostedReviewCache) const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) const updateRepo = useAppStore((s) => s.updateRepo) @@ -366,6 +378,7 @@ function SourceControlInner(): React.JSX.Element { } const connectionId = getConnectionId(activeWorktreeId) ?? undefined await refreshGitStatusForWorktree({ + settings: useAppStore.getState().settings, worktreeId: activeWorktreeId, worktreePath, connectionId, @@ -407,8 +420,7 @@ function SourceControlInner(): React.JSX.Element { setDefaultBaseRef(null) let stale = false - void window.api.repos - .getBaseRefDefault({ repoId: activeRepo.id }) + void getRuntimeRepoBaseRefDefault(useAppStore.getState().settings, activeRepo.id) .then((result) => { if (!stale) { // Why: IPC now returns a `{ defaultBaseRef, remoteCount }` envelope; @@ -436,7 +448,8 @@ function SourceControlInner(): React.JSX.Element { const hasUncommittedEntries = entries.length > 0 const branchName = activeWorktree?.branch.replace(/^refs\/heads\//, '') ?? 'HEAD' - const hostedReviewCacheKey = activeRepo && branchName ? `${activeRepo.path}::${branchName}` : null + const hostedReviewCacheKey = + activeRepo && branchName ? getHostedReviewCacheKey(activeRepo.path, branchName, settings) : null const hostedReview: HostedReviewInfo | null = hostedReviewCacheKey ? (hostedReviewCache[hostedReviewCacheKey]?.data ?? null) : null @@ -612,11 +625,15 @@ function SourceControlInner(): React.JSX.Element { setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) setCommitErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) try { - const commitResult = await window.api.git.commit({ - worktreePath, - message, - connectionId - }) + const commitResult = await commitRuntimeGit( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + message + ) if (!commitResult.success) { setCommitErrors((prev) => ({ ...prev, @@ -948,7 +965,15 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(true) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.bulkStage({ worktreePath, filePaths: bulkStagePaths, connectionId }) + await bulkStageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + bulkStagePaths + ) await refreshActiveGitStatusAfterMutation() clearSelection() } finally { @@ -969,7 +994,15 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(true) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.bulkUnstage({ worktreePath, filePaths: bulkUnstagePaths, connectionId }) + await bulkUnstageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + bulkUnstagePaths + ) await refreshActiveGitStatusAfterMutation() clearSelection() } finally { @@ -999,7 +1032,15 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(true) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.bulkStage({ worktreePath, filePaths: paths, connectionId }) + await bulkStageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + paths + ) await refreshActiveGitStatusAfterMutation() clearSelection() } finally { @@ -1034,7 +1075,15 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(true) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.bulkStage({ worktreePath, filePaths, connectionId }) + await bulkStageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePaths + ) await refreshActiveGitStatusAfterMutation() clearSelection() } finally { @@ -1086,7 +1135,15 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(true) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.bulkUnstage({ worktreePath, filePaths: paths, connectionId }) + await bulkUnstageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + paths + ) await refreshActiveGitStatusAfterMutation() clearSelection() } finally { @@ -1132,11 +1189,15 @@ function SourceControlInner(): React.JSX.Element { try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - const result = await window.api.git.branchCompare({ - worktreePath, - baseRef: effectiveBaseRef, - connectionId - }) + const result = await getRuntimeGitBranchCompare( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + effectiveBaseRef + ) setGitBranchCompareResult(activeWorktreeId, requestKey, result) } catch (error) { setGitBranchCompareResult(activeWorktreeId, requestKey, { @@ -1314,7 +1375,15 @@ function SourceControlInner(): React.JSX.Element { } try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.stage({ worktreePath, filePath, connectionId }) + await stageRuntimeGitPath( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePath + ) await refreshActiveGitStatusAfterMutation() } catch { // git operation failed silently @@ -1330,7 +1399,15 @@ function SourceControlInner(): React.JSX.Element { } try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.unstage({ worktreePath, filePath, connectionId }) + await unstageRuntimeGitPath( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePath + ) await refreshActiveGitStatusAfterMutation() } catch { // git operation failed silently @@ -1357,7 +1434,15 @@ function SourceControlInner(): React.JSX.Element { relativePath: filePath }) const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.discard({ worktreePath, filePath, connectionId }) + await discardRuntimeGitPath( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePath + ) notifyEditorExternalFileChange({ worktreeId: activeWorktreeId, worktreePath, @@ -1438,7 +1523,15 @@ function SourceControlInner(): React.JSX.Element { const errors: unknown[] = [] const result = await runDiscardAllForArea(area, paths, { bulkUnstage: (filePaths) => - window.api.git.bulkUnstage({ worktreePath, filePaths, connectionId }), + bulkUnstageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePaths + ), discardMany, discardOne: discardSingle, onError: (error) => { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 3929c54065d..d7fafc21d8e 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -1,4 +1,5 @@ -import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/types' +import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' +import type { GitStatusResult, GitUpstreamStatus, GlobalSettings } from '../../../../shared/types' export type GitStatusRefreshDeps = { setGitStatus: (worktreeId: string, status: GitStatusResult) => void @@ -15,17 +16,21 @@ export type GitStatusRefreshDeps = { } export async function refreshGitStatusForWorktree({ + settings, worktreeId, worktreePath, connectionId, deps }: { + settings?: Pick | null worktreeId: string worktreePath: string connectionId?: string deps: GitStatusRefreshDeps }): Promise { - const status = (await window.api.git.status({ + const status = (await getRuntimeGitStatus({ + settings, + worktreeId, worktreePath, connectionId })) as GitStatusResult diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 44996e80790..6aeb95dd24b 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { dirname } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' +import { findWorktreeById } from '@/store/slices/worktree-helpers' import { isPathEqualOrDescendant } from './file-explorer-paths' import type { TreeNode } from './file-explorer-types' import { @@ -11,6 +12,12 @@ import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from './fileExplorerUndoRedo' +import { + deleteRuntimePath, + isRemoteRuntimeFileOperation, + readRuntimeFileContent, + writeRuntimeFile +} from '@/runtime/runtime-file-client' type UseFileDeletionParams = { activeWorktreeId: string | null @@ -54,7 +61,18 @@ export function useFileDeletion({ inFlightRef.current.add(node.path) const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - const isRemote = connectionId !== undefined + const state = useAppStore.getState() + const worktree = activeWorktreeId + ? findWorktreeById(state.worktreesByRepo, activeWorktreeId) + : null + const fileContext = { + settings: state.settings, + worktreeId: activeWorktreeId, + worktreePath: worktree?.path ?? null, + connectionId + } + const isRemote = + connectionId !== undefined || isRemoteRuntimeFileOperation(fileContext, node.path) // Why: remote deletes go through `rm` on the relay — there is no OS-level // Trash/Recycle Bin, so the operation is permanent. Require an explicit @@ -93,7 +111,13 @@ export function useFileDeletion({ let undoContent: string | undefined if (!node.isDirectory) { try { - const rf = await window.api.fs.readFile({ filePath: node.path, connectionId }) + const rf = await readRuntimeFileContent({ + settings: fileContext.settings, + filePath: node.path, + relativePath: node.relativePath, + worktreeId: activeWorktreeId ?? undefined, + connectionId + }) if (!rf.isBinary) { undoContent = rf.content } @@ -103,28 +127,16 @@ export function useFileDeletion({ } } - await window.api.fs.deletePath({ - targetPath: node.path, - connectionId, - recursive: node.isDirectory - }) + await deleteRuntimePath(fileContext, node.path, node.isDirectory) if (undoContent !== undefined) { commitFileExplorerOp({ undo: async () => { - await window.api.fs.writeFile({ - filePath: node.path, - content: undoContent, - connectionId - }) + await writeRuntimeFile(fileContext, node.path, undoContent) await refreshDir(parentDir) }, redo: async () => { - await window.api.fs.deletePath({ - targetPath: node.path, - connectionId, - recursive: node.isDirectory - }) + await deleteRuntimePath(fileContext, node.path, node.isDirectory) await refreshDir(parentDir) } }) diff --git a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts index 255b380ca5a..82ef96b1353 100644 --- a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts +++ b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts @@ -2,6 +2,9 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { basename, dirname, joinPath } from '@/lib/path' import type { TreeNode } from './file-explorer-types' +import { useAppStore } from '@/store' +import { copyRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client' +import { getConnectionId } from '@/lib/connection-context' /** * Electron's ipcRenderer.invoke wraps errors as: @@ -17,11 +20,13 @@ function extractIpcErrorMessage(err: unknown, fallback: string): string { } type UseFileDuplicateParams = { + activeWorktreeId: string | null worktreePath: string | null refreshDir: (dirPath: string) => Promise } export function useFileDuplicate({ + activeWorktreeId, worktreePath, refreshDir }: UseFileDuplicateParams): (node: TreeNode) => void { @@ -37,12 +42,19 @@ export function useFileDuplicate({ const ext = dotIndex > 0 ? name.slice(dotIndex) : '' const run = async (): Promise => { + const settings = useAppStore.getState().settings + const context = { + settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId: getConnectionId(activeWorktreeId) ?? undefined + } // Why: generate a unique "stem copy.ext", "stem copy 2.ext", … name // so we never collide with an existing file. pathExists checks are // sequential to avoid TOCTOU races with COPYFILE_EXCL on the backend. let candidate = joinPath(dir, `${stem} copy${ext}`) let n = 2 - while (await window.api.shell.pathExists(candidate)) { + while (await runtimePathExists(context, candidate)) { candidate = joinPath(dir, `${stem} copy ${n}${ext}`) n += 1 } @@ -58,7 +70,7 @@ export function useFileDuplicate({ // eslint-disable-next-line no-constant-condition while (true) { try { - await window.api.shell.copyFile({ srcPath: node.path, destPath: candidate }) + await copyRuntimePath(context, node.path, candidate) break } catch (err) { const isEexist = @@ -87,6 +99,6 @@ export function useFileDuplicate({ } void run() }, - [worktreePath, refreshDir] + [activeWorktreeId, worktreePath, refreshDir] ) } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index 30c8f964881..32716165c12 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -10,6 +10,7 @@ import { detectLanguage } from '@/lib/language-detect' import { getConnectionId } from '@/lib/connection-context' import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from './fileExplorerUndoRedo' +import { renameRuntimePath } from '@/runtime/runtime-file-client' function extractIpcErrorMessage(err: unknown, fallback: string): string { if (!(err instanceof Error)) { @@ -281,16 +282,22 @@ export function useFileExplorerDragDrop({ try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.fs.rename({ oldPath: sourcePath, newPath, connectionId }) + const fileContext = { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + } + await renameRuntimePath(fileContext, sourcePath, newPath) commitFileExplorerOp({ undo: async () => { - await window.api.fs.rename({ oldPath: newPath, newPath: sourcePath, connectionId }) + await renameRuntimePath(fileContext, newPath, sourcePath) await Promise.all([refreshDir(destDir), refreshDir(sourceDir)]) remapOpenTabsForMovedPath(newPath, sourcePath) }, redo: async () => { - await window.api.fs.rename({ oldPath: sourcePath, newPath, connectionId }) + await renameRuntimePath(fileContext, sourcePath, newPath) await Promise.all([refreshDir(sourceDir), refreshDir(destDir)]) remapOpenTabsForMovedPath(sourcePath, newPath) } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts index b08ddb30532..9a253a476ab 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts @@ -3,6 +3,8 @@ import type { Dispatch, SetStateAction } from 'react' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { useAppStore } from '@/store' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' type UseFileExplorerImportParams = { worktreePath: string | null @@ -61,21 +63,26 @@ export function useFileExplorerImport({ void (async () => { try { - const { results } = await window.api.fs.importExternalPaths({ - sourcePaths: paths, - destDir: destinationDir, - connectionId - }) + const settings = useAppStore.getState().settings + const { results } = await importExternalPathsToRuntime( + { + settings, + worktreeId: wtId, + worktreePath: worktreePathRef.current, + connectionId + }, + paths, + destinationDir + ) // Refresh the destination directory once per gesture await refreshDirRef.current(destinationDir) // Why: only select (highlight) the first imported file — don't trigger - // the full reveal machinery (scrollToIndex + flash) because the user - // already knows where they dropped the file. The reveal's aggressive - // scroll-to-center races with FS watcher refreshes and can snap the - // viewport back to the top of the tree. + // the full reveal machinery because watcher refreshes can otherwise + // snap the tree viewport away from the user's drop target. const imported = results.filter((r) => r.status === 'imported') + const skipped = results.filter((r) => r.status === 'skipped') const failed = results.filter((r) => r.status === 'failed') if (imported.length > 0) { @@ -85,6 +92,9 @@ export function useFileExplorerImport({ if (failed.length > 0) { const noun = failed.length === 1 ? 'file' : 'files' toast.error(`Failed to import ${failed.length} ${noun}.`) + } else if (skipped.length > 0 && imported.length === 0) { + const noun = skipped.length === 1 ? 'file' : 'files' + toast.error(`Skipped ${skipped.length} ${noun}.`) } } catch (err) { toast.error(extractIpcErrorMessage(err, 'Failed to import files.')) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts index 65d495437be..a4064115397 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts @@ -9,6 +9,7 @@ import { extractIpcErrorMessage, renameFileOnDisk } from '@/lib/rename-file' import type { InlineInput } from './FileExplorerRow' import type { TreeNode } from './file-explorer-types' import { commitFileExplorerOp } from './fileExplorerUndoRedo' +import { createRuntimePath, deleteRuntimePath } from '@/runtime/runtime-file-client' type UseFileExplorerInlineInputParams = { activeWorktreeId: string | null @@ -110,6 +111,12 @@ export function useFileExplorerInlineInput({ } const run = async (): Promise => { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const fileContext = { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + } if (inlineInput.type === 'rename' && inlineInput.existingPath) { await renameFileOnDisk({ oldPath: inlineInput.existingPath, @@ -121,29 +128,31 @@ export function useFileExplorerInlineInput({ } else { const fullPath = joinPath(inlineInput.parentPath, name) try { - await (inlineInput.type === 'folder' - ? window.api.fs.createDir({ dirPath: fullPath, connectionId }) - : window.api.fs.createFile({ filePath: fullPath, connectionId })) + await createRuntimePath( + fileContext, + fullPath, + inlineInput.type === 'folder' ? 'directory' : 'file' + ) const parentForRefresh = inlineInput.parentPath if (inlineInput.type === 'folder') { commitFileExplorerOp({ undo: async () => { - await window.api.fs.deletePath({ targetPath: fullPath, connectionId }) + await deleteRuntimePath(fileContext, fullPath, true) await refreshDir(parentForRefresh) }, redo: async () => { - await window.api.fs.createDir({ dirPath: fullPath, connectionId }) + await createRuntimePath(fileContext, fullPath, 'directory') await refreshDir(parentForRefresh) } }) } else { commitFileExplorerOp({ undo: async () => { - await window.api.fs.deletePath({ targetPath: fullPath, connectionId }) + await deleteRuntimePath(fileContext, fullPath) await refreshDir(parentForRefresh) }, redo: async () => { - await window.api.fs.createFile({ filePath: fullPath, connectionId }) + await createRuntimePath(fileContext, fullPath, 'file') await refreshDir(parentForRefresh) } }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index a09c31b3333..7c52f0a756d 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -5,6 +5,8 @@ import { getConnectionId } from '@/lib/connection-context' import type { DirCache, TreeNode } from './file-explorer-types' import { splitPathSegments } from './path-tree' import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' +import { readRuntimeDirectory } from '@/runtime/runtime-file-client' +import { useAppStore } from '@/store' type UseFileExplorerTreeResult = { dirCache: Record @@ -48,7 +50,15 @@ export function useFileExplorerTree( })) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - const entries = await window.api.fs.readDir({ dirPath, connectionId }) + const entries = await readRuntimeDirectory( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + dirPath + ) if (depth === -1) { setRootError(null) } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts index 2d0c9290074..6aae3550b35 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { FsChangedPayload } from '../../../../shared/types' import { + canonicalizeFileExplorerWatchPath, getExternalFileChangeRelativePath, payloadRequiresDeferredTreeRefresh } from './useFileExplorerWatch' @@ -32,6 +33,22 @@ describe('getExternalFileChangeRelativePath', () => { ).toBe('config/settings.json') }) + it('matches Windows paths case-insensitively before deriving the relative path', () => { + expect( + getExternalFileChangeRelativePath('C:\\Repo', 'c:\\repo\\config\\settings.json', false) + ).toBe('config/settings.json') + }) + + it('preserves UNC roots when deriving the relative path', () => { + expect( + getExternalFileChangeRelativePath( + '//Server/Share/Repo', + '//server/share/repo/config/settings.json', + false + ) + ).toBe('config/settings.json') + }) + it('ignores paths outside the active worktree', () => { expect( getExternalFileChangeRelativePath('/repo', '/other/config/settings.json', false) @@ -62,6 +79,29 @@ describe('getExternalFileChangeRelativePath', () => { }) }) +describe('canonicalizeFileExplorerWatchPath', () => { + it('returns event paths with the watched worktree casing for UNC cache lookups', () => { + expect( + canonicalizeFileExplorerWatchPath('//Server/Share/Repo', '//server/share/repo/src/index.ts') + ).toBe('//Server/Share/Repo/src/index.ts') + }) + + it('preserves the watched worktree separator style for Windows cache lookups', () => { + expect(canonicalizeFileExplorerWatchPath('C:\\Repo', 'c:\\repo\\src\\index.ts')).toBe( + 'C:\\Repo\\src\\index.ts' + ) + }) + + it('rejects sibling UNC shares whose path merely shares a prefix', () => { + expect( + canonicalizeFileExplorerWatchPath( + '//Server/Share/Repo', + '//server/share/repository/src/index.ts' + ) + ).toBeNull() + }) +}) + describe('payloadRequiresDeferredTreeRefresh', () => { function payload(events: FsChangedPayload['events'], worktreePath = '/repo'): FsChangedPayload { return { worktreePath, events } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index 22c9dbd0205..5f926604e81 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -3,14 +3,19 @@ import type { Dispatch, SetStateAction } from 'react' import type { FsChangedPayload } from '../../../../shared/types' import type { DirCache } from './file-explorer-types' import type { InlineInput } from './FileExplorerRow' -import { normalizeRelativePath } from '@/lib/path' -import { normalizeAbsolutePath } from './file-explorer-paths' -import { dirname } from '@/lib/path' +import { joinPath, normalizeRelativePath, dirname } from '@/lib/path' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../../../shared/cross-platform-path' import { purgeDirCacheSubtree, purgeExpandedDirsSubtree, clearStalePendingReveal } from './file-explorer-watcher-reconcile' +import { useAppStore } from '@/store' +import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' type UseFileExplorerWatchParams = { worktreePath: string | null @@ -35,17 +40,8 @@ export function getExternalFileChangeRelativePath( return null } - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath) - const normalizedAbsolutePath = normalizeAbsolutePath(absolutePath) - // Why: `normalizeAbsolutePath` preserves the trailing slash for filesystem - // roots (`/` on POSIX, `C:/` on Windows drive roots) but strips it from all - // other paths. Blindly appending `/` would produce `//` or `C://`, so - // external edits under a root worktree would fail the prefix check and be - // silently dropped. Treat those roots as already-terminated prefixes. - const isFsRoot = normalizedWorktreePath === '/' || /^[A-Za-z]:\/$/.test(normalizedWorktreePath) - const worktreePrefix = isFsRoot ? normalizedWorktreePath : `${normalizedWorktreePath}/` - - if (!normalizedAbsolutePath.startsWith(worktreePrefix)) { + const relativePath = relativePathInsideRoot(worktreePath, absolutePath) + if (relativePath === null || relativePath === '') { return null } @@ -54,14 +50,37 @@ export function getExternalFileChangeRelativePath( // filesystem watcher reports absolute paths, so normalize them here before // the explorer refresh path returns; otherwise terminal edits refresh the // tree but leave the editor's cached file contents stale. - return normalizeRelativePath(normalizedAbsolutePath.slice(worktreePrefix.length)) + return normalizeRelativePath(relativePath) +} + +export function canonicalizeFileExplorerWatchPath( + worktreePath: string, + absolutePath: string +): string | null { + const relativePath = relativePathInsideRoot(worktreePath, absolutePath) + if (relativePath === null) { + return null + } + + const rootPath = normalizeExplorerAbsolutePath(worktreePath) + return relativePath === '' ? rootPath : joinPath(rootPath, relativePath) +} + +function normalizeExplorerAbsolutePath(path: string): string { + if (path === '/' || /^[A-Za-z]:[\\/]$/.test(path)) { + return path + } + return path.replace(/[\\/]+$/, '') } export function payloadRequiresDeferredTreeRefresh( payload: FsChangedPayload, currentWorktreePath: string ): boolean { - if (normalizeAbsolutePath(payload.worktreePath) !== normalizeAbsolutePath(currentWorktreePath)) { + if ( + normalizeRuntimePathForComparison(payload.worktreePath) !== + normalizeRuntimePathForComparison(currentWorktreePath) + ) { return false } @@ -91,6 +110,8 @@ export function useFileExplorerWatch({ dragSourcePath, isNativeDragOver }: UseFileExplorerWatchParams): void { + const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) + // Keep refs for values accessed inside the event handler to avoid // re-subscribing the IPC listener on every render. const dirCacheRef = useRef(dirCache) @@ -151,7 +172,8 @@ export function useFileExplorerWatch({ // the old worktree can arrive after the switch. Processing them against // the new worktree's tree state would corrupt dirCache (design §3). if ( - normalizeAbsolutePath(payload.worktreePath) !== normalizeAbsolutePath(currentWorktreePath) + normalizeRuntimePathForComparison(payload.worktreePath) !== + normalizeRuntimePathForComparison(currentWorktreePath) ) { return } @@ -169,13 +191,19 @@ export function useFileExplorerWatch({ let needsFullRefresh = false for (const evt of payload.events) { - const normalizedPath = normalizeAbsolutePath(evt.absolutePath) - if (evt.kind === 'overflow') { needsFullRefresh = true break } + const normalizedPath = canonicalizeFileExplorerWatchPath( + currentWorktreePath, + evt.absolutePath + ) + if (!normalizedPath) { + continue + } + if (evt.kind === 'delete') { // Why: for delete events, isDirectory is undefined from the watcher // (the path no longer exists). Infer from dirCache: if the deleted @@ -193,27 +221,27 @@ export function useFileExplorerWatch({ // Clear selectedPath if it points into the deleted subtree setSelectedPath((prev) => { - if (prev && normalizeAbsolutePath(prev) === normalizedPath) { - return null - } if ( prev && - wasDirectory && - normalizeAbsolutePath(prev).startsWith(`${normalizedPath}/`) + normalizeRuntimePathForComparison(prev) === + normalizeRuntimePathForComparison(normalizedPath) ) { return null } + if (prev && wasDirectory && isPathInsideOrEqual(normalizedPath, prev)) { + return null + } return prev }) // Invalidate the parent directory - const parent = normalizeAbsolutePath(dirname(normalizedPath)) + const parent = normalizeExplorerAbsolutePath(dirname(normalizedPath)) if (parent in cache) { dirsToRefresh.add(parent) } } else if (evt.kind === 'create') { // Invalidate the parent directory - const parent = normalizeAbsolutePath(dirname(normalizedPath)) + const parent = normalizeExplorerAbsolutePath(dirname(normalizedPath)) if (parent in cache) { dirsToRefresh.add(parent) } @@ -239,7 +267,7 @@ export function useFileExplorerWatch({ for (const dirPath of dirsToRefresh) { // Check the dir is the root or an expanded directory or already in cache if ( - dirPath === normalizeAbsolutePath(currentWorktreePath) || + dirPath === normalizeExplorerAbsolutePath(currentWorktreePath) || exp.has(dirPath) || dirPath in dirCacheRef.current ) { @@ -271,14 +299,52 @@ export function useFileExplorerWatch({ processPayload(payload) } - const unsubscribeListener = window.api.fs.onFsChanged(handleFsChanged) + let disposed = false + let unsubscribeListener: (() => void) | null = null + if (activeRuntimeEnvironmentId?.trim() && activeWorktreeId) { + // Why: remote runtime watch events do not enter the local Electron + // fs:changed bus, so the Explorer subscribes directly while it is mounted. + void subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId }, + worktreeId: activeWorktreeId, + worktreePath, + connectionId: undefined + }, + handleFsChanged, + (err) => { + console.warn('[filesystem-watch] failed to subscribe to runtime file changes', { + worktreeId: activeWorktreeId, + worktreePath, + error: err.message + }) + } + ) + .then((unsubscribe) => { + if (disposed) { + unsubscribe() + return + } + unsubscribeListener = unsubscribe + }) + .catch((err) => { + console.warn('[filesystem-watch] failed to subscribe to runtime file changes', { + worktreeId: activeWorktreeId, + worktreePath, + error: err instanceof Error ? err.message : String(err) + }) + }) + } else { + unsubscribeListener = window.api.fs.onFsChanged(handleFsChanged) + } return () => { - unsubscribeListener() + disposed = true + unsubscribeListener?.() deferredRef.current = [] processPayloadRef.current = null } - }, [worktreePath, activeWorktreeId, setDirCache, setSelectedPath]) + }, [worktreePath, activeWorktreeId, activeRuntimeEnvironmentId, setDirCache, setSelectedPath]) // ── Flush deferred events when interaction ends ──────────────────── useEffect(() => { diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 89121ff88bc..a871ca1a4e7 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -48,7 +48,9 @@ async function usePollingOnce(status: GitStatusResult): Promise { }) vi.doMock('@/store', () => ({ - useAppStore: (selector: (s: PollState) => unknown) => selector(state) + useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), { + getState: () => ({ settings: null }) + }) })) vi.doMock('@/store/selectors', () => ({ @@ -78,7 +80,9 @@ async function usePollingOnce(status: GitStatusResult): Promise { const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling') GitStatusPollingHarness({ runPolling }) - await Promise.resolve() + await vi.waitFor(() => { + expect(state.setGitStatus).toHaveBeenCalled() + }) return state } diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 4ef3c3770e9..07874284dfe 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -4,6 +4,7 @@ import { useActiveWorktree, useAllWorktrees, useRepoById, useRepoMap } from '@/s import type { GitConflictOperation } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' import { refreshGitStatusForWorktree } from './git-status-refresh' const POLL_INTERVAL_MS = 3000 @@ -54,6 +55,7 @@ export function useGitStatusPolling(): void { try { const connectionId = getConnectionId(activeWorktreeId) ?? undefined await refreshGitStatusForWorktree({ + settings: useAppStore.getState().settings, worktreeId: activeWorktreeId, worktreePath, connectionId, @@ -109,7 +111,9 @@ export function useGitStatusPolling(): void { const pollStale = async (): Promise => { for (const { id, path } of staleConflictWorktrees) { try { - const op = (await window.api.git.conflictOperation({ + const op = (await getRuntimeGitConflictOperation({ + settings: useAppStore.getState().settings, + worktreeId: id, worktreePath: path, connectionId: getConnectionId(id) ?? undefined })) as GitConflictOperation diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx index b5e586f317e..e3c8ddd67d9 100644 --- a/src/renderer/src/components/settings/BaseRefPicker.tsx +++ b/src/renderer/src/components/settings/BaseRefPicker.tsx @@ -2,6 +2,11 @@ import { useEffect, useState } from 'react' import { ScrollArea } from '../ui/scroll-area' import { Button } from '../ui/button' import { Input } from '../ui/input' +import { useAppStore } from '@/store' +import { + getRuntimeRepoBaseRefDefault, + searchRuntimeRepoBaseRefs +} from '@/runtime/runtime-repo-client' type BaseRefPickerProps = { repoId: string @@ -16,6 +21,9 @@ export function BaseRefPicker({ onSelect, onUsePrimary }: BaseRefPickerProps): React.JSX.Element { + const activeRuntimeEnvironmentId = useAppStore( + (state) => state.settings?.activeRuntimeEnvironmentId ?? null + ) // Why: null until the IPC resolves (or when the repo has no default base ref // available). We avoid seeding with 'origin/main' because that would display // a fabricated default in repos that don't actually have origin/main. @@ -34,7 +42,7 @@ export function BaseRefPicker({ const loadDefaultBaseRef = async (): Promise => { try { - const result = await window.api.repos.getBaseRefDefault({ repoId }) + const result = await getRuntimeRepoBaseRefDefault({ activeRuntimeEnvironmentId }, repoId) if (!stale) { setDefaultBaseRef(result.defaultBaseRef) setRemoteCount(result.remoteCount) @@ -60,7 +68,7 @@ export function BaseRefPicker({ return () => { stale = true } - }, [repoId]) + }, [activeRuntimeEnvironmentId, repoId]) useEffect(() => { const trimmedQuery = baseRefQuery.trim() @@ -74,12 +82,7 @@ export function BaseRefPicker({ setIsSearchingBaseRefs(true) const timer = window.setTimeout(() => { - void window.api.repos - .searchBaseRefs({ - repoId, - query: trimmedQuery, - limit: 20 - }) + void searchRuntimeRepoBaseRefs({ activeRuntimeEnvironmentId }, repoId, trimmedQuery, 20) .then((results) => { if (!stale) { setBaseRefResults(results) @@ -102,7 +105,7 @@ export function BaseRefPicker({ stale = true window.clearTimeout(timer) } - }, [baseRefQuery, repoId]) + }, [activeRuntimeEnvironmentId, baseRefQuery, repoId]) const effectiveBaseRef = currentBaseRef ?? defaultBaseRef diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index 92148821647..8efeafa903d 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -96,6 +96,9 @@ function getConfirmCopy(confirm: PendingConfirm): { } export function ManageSessionsSection(): React.JSX.Element { + const activeRuntimeEnvironmentId = useAppStore( + (s) => s.settings?.activeRuntimeEnvironmentId ?? null + ) const [sessions, setSessions] = useState([]) const [isRefreshing, setIsRefreshing] = useState(true) const [hasLoadedOnce, setHasLoadedOnce] = useState(false) @@ -176,6 +179,14 @@ export function ManageSessionsSection(): React.JSX.Element { }, []) const refresh = useCallback(async (): Promise => { + if (activeRuntimeEnvironmentId?.trim()) { + if (isMounted.current) { + setSessions([]) + setIsRefreshing(false) + setHasLoadedOnce(true) + } + return [] + } setIsRefreshing(true) try { const result = await window.api.pty.management.listSessions() @@ -198,7 +209,7 @@ export function ManageSessionsSection(): React.JSX.Element { setHasLoadedOnce(true) } } - }, []) + }, [activeRuntimeEnvironmentId]) useEffect(() => { void refresh() @@ -275,6 +286,29 @@ export function ManageSessionsSection(): React.JSX.Element { const copy = useMemo(() => getConfirmCopy(confirm), [confirm]) const isBusy = busyKind !== null || daemonActions.isBusy + if (activeRuntimeEnvironmentId?.trim()) { + return ( +
+
+

Manage Sessions

+

+ Session management is unavailable while a remote runtime server is active. +

+
+ +
+ Switch back to the local runtime to restart or kill local daemon sessions. +
+
+
+ ) + } + return (
diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index fc5b9505638..6e6cbf23fc9 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -5,6 +5,8 @@ import { AlertTriangle } from 'lucide-react' import { toast } from 'sonner' import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' +import { useAppStore } from '@/store' +import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' type RepositoryHooksSectionProps = { repo: Repo @@ -154,6 +156,7 @@ export function RepositoryHooksSection({ onClearLegacyHooks, onUpdateSetupRunPolicy }: RepositoryHooksSectionProps): React.JSX.Element { + const settings = useAppStore((s) => s.settings) // Why: distinguish "file has unrecognised top-level keys" from "file is // genuinely malformed" so users see a helpful update prompt instead of a // confusing parse-error when a newer Orca version adds keys to `orca.yaml`. @@ -192,8 +195,7 @@ export function RepositoryHooksSection({ // Why: settings only edit the local override, but we still need to know // whether `orca.yaml` defines a shared default so the helper copy can // explain what happens when the override is blank. - void window.api.hooks - .readIssueCommand({ repoId }) + void readRuntimeIssueCommand(settings, repoId) .then((result) => { if (cancelled) { return @@ -215,18 +217,18 @@ export function RepositoryHooksSection({ cancelled = true const draft = issueCommandDraftRef.current.trim() if (draft !== lastCommittedIssueCommandRef.current) { - void window.api.hooks.writeIssueCommand({ repoId, content: draft }).catch((err) => { + void writeRuntimeIssueCommand(settings, repoId, draft).catch((err) => { console.error('[RepositoryHooksSection] Failed to save issue command on unmount:', err) }) } } - }, [repo.id]) + }, [repo.id, settings]) const commitIssueCommand = useCallback(async (): Promise => { const trimmed = issueCommandDraft.trim() setIssueCommandDraft(trimmed) try { - await window.api.hooks.writeIssueCommand({ repoId: repo.id, content: trimmed }) + await writeRuntimeIssueCommand(settings, repo.id, trimmed) lastCommittedIssueCommandRef.current = trimmed setIssueCommandSaveError(null) } catch (err) { @@ -235,7 +237,7 @@ export function RepositoryHooksSection({ setIssueCommandSaveError(message) toast.error(message) } - }, [issueCommandDraft, repo.id]) + }, [issueCommandDraft, repo.id, settings]) return (
diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx new file mode 100644 index 00000000000..b6254ba3226 --- /dev/null +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -0,0 +1,413 @@ +import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import type { GlobalSettings } from '../../../../shared/types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import type { SettingsSearchEntry } from './settings-search' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +const LOCAL_RUNTIME_VALUE = '__local__' + +export const RUNTIME_ENVIRONMENTS_SEARCH_ENTRY: SettingsSearchEntry = { + title: 'Active Server', + description: 'Choose local desktop or a saved remote Orca server.', + keywords: [ + 'runtime', + 'environment', + 'server', + 'client', + 'remote', + 'pairing', + 'cloud', + 'vm', + 'dev box' + ] +} + +type RuntimeEnvironmentsPaneProps = { + settings: GlobalSettings + switchRuntimeEnvironment: (environmentId: string | null) => Promise +} + +export function RuntimeEnvironmentsPane({ + settings, + switchRuntimeEnvironment +}: RuntimeEnvironmentsPaneProps): React.JSX.Element { + const [environments, setEnvironments] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [switchingValue, setSwitchingValue] = useState(null) + const [removingId, setRemovingId] = useState(null) + const [pendingSwitchValue, setPendingSwitchValue] = useState(null) + const [pendingRemove, setPendingRemove] = useState(null) + const [switchError, setSwitchError] = useState(null) + const [removeError, setRemoveError] = useState(null) + const [name, setName] = useState('') + const [pairingCode, setPairingCode] = useState('') + const activeValue = settings.activeRuntimeEnvironmentId ?? LOCAL_RUNTIME_VALUE + const isBusy = isSaving || switchingValue !== null || removingId !== null + const removingActiveServer = pendingRemove?.id === settings.activeRuntimeEnvironmentId + + const loadEnvironments = async (): Promise => { + setIsLoading(true) + try { + setEnvironments(await window.api.runtimeEnvironments.list()) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to load runtime environments.') + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + void loadEnvironments() + }, []) + + const addEnvironment = async (): Promise => { + const trimmedName = name.trim() + const trimmedPairingCode = pairingCode.trim() + if (!trimmedName || !trimmedPairingCode) { + toast.error('Name and pairing code are required.') + return + } + const duplicate = environments.find( + (environment) => environment.name.trim().toLowerCase() === trimmedName.toLowerCase() + ) + if (duplicate) { + toast.error(`A server named "${duplicate.name}" already exists.`) + return + } + setIsSaving(true) + try { + const result = await window.api.runtimeEnvironments.addFromPairingCode({ + name: trimmedName, + pairingCode: trimmedPairingCode + }) + setName('') + setPairingCode('') + await loadEnvironments() + toast.success(`Saved ${result.environment.name}. Use Active Server to switch when ready.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to save runtime environment.') + } finally { + setIsSaving(false) + } + } + + const removeEnvironment = async ( + environment: PublicKnownRuntimeEnvironment + ): Promise => { + setRemovingId(environment.id) + setRemoveError(null) + try { + if (settings.activeRuntimeEnvironmentId === environment.id) { + const switched = await switchRuntimeEnvironment(null) + if (!switched) { + setRemoveError('Could not switch to Local desktop. Fix the issue and try again.') + return false + } + } + await window.api.runtimeEnvironments.remove({ selector: environment.id }) + await loadEnvironments() + toast.success(`Removed ${environment.name}.`) + return true + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to remove runtime environment.' + setRemoveError(message) + toast.error(message) + return false + } finally { + setRemovingId(null) + } + } + + const switchToValue = async (value: string): Promise => { + setSwitchingValue(value) + setSwitchError(null) + try { + const switched = await switchRuntimeEnvironment(value === LOCAL_RUNTIME_VALUE ? null : value) + if (switched) { + toast.success(`Switched to ${getEnvironmentLabel(value)}.`) + return true + } + setSwitchError('Could not switch servers. Fix the issue and try again.') + return false + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to switch servers.' + setSwitchError(message) + toast.error(message) + return false + } finally { + setSwitchingValue(null) + } + } + + const getEnvironmentLabel = (value: string): string => { + if (value === LOCAL_RUNTIME_VALUE) { + return 'Local desktop' + } + return environments.find((environment) => environment.id === value)?.name ?? 'remote server' + } + + return ( + +
+
+ +

+ Local keeps today's desktop behavior. Saved servers route supported client calls + through the remote runtime. +

+
+
+ + +
+
+ +
+
+ + setName(event.target.value)} + placeholder="Dev box" + className="h-8 text-xs" + /> +
+
+ + setPairingCode(event.target.value)} + placeholder="orca://pair#..." + className="h-8 min-w-0 font-mono text-xs" + /> +

+ Run orca serve --pairing-address <host> on the + server and paste the printed pairing URL. +

+
+ +
+ +
+ {environments.length === 0 ? ( +
No saved servers.
+ ) : ( +
+ {environments.map((environment) => ( +
+
+
{environment.name}
+
+ {environment.endpoints[0]?.endpoint ?? 'No endpoint'} +
+
+ +
+ ))} +
+ )} +
+ + { + if (!open && switchingValue === null) { + setSwitchError(null) + setPendingSwitchValue(null) + } + }} + > + + + Switch Server + + Orca will close remote terminals and browser tabs from the current server before + loading projects from the next server. + + + {pendingSwitchValue ? ( +
+
Switch to
+
+ {getEnvironmentLabel(pendingSwitchValue)} +
+
+ ) : null} + {switchError ?

{switchError}

: null} + + + + +
+
+ + { + if (!open && removingId === null) { + setRemoveError(null) + setPendingRemove(null) + } + }} + > + + + Remove Server + + {removingActiveServer + ? 'Removing the active server first switches Orca back to Local desktop and closes remote terminals and browser tabs for that server.' + : 'This removes the saved server from Orca. It does not change the active server.'} + + + {pendingRemove ? ( +
+
{pendingRemove.name}
+
+ {pendingRemove.endpoints[0]?.endpoint ?? 'No endpoint'} +
+
+ ) : null} + {removeError ?

{removeError}

: null} + + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 4932313f9bf..9e0cc8cb531 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -4,6 +4,7 @@ import { BarChart3, Bell, Bot, + Cable, FlaskConical, GitBranch, Globe, @@ -59,11 +60,16 @@ import { } from './DeveloperPermissionsPane' import { ComputerUsePane, COMPUTER_USE_PANE_SEARCH_ENTRIES } from './ComputerUsePane' import { MobileSettingsPane, MOBILE_SETTINGS_PANE_SEARCH_ENTRIES } from './MobileSettingsPane' +import { + RuntimeEnvironmentsPane, + RUNTIME_ENVIRONMENTS_SEARCH_ENTRY +} from './RuntimeEnvironmentsPane' import { PrivacyPane } from './PrivacyPane' import { PRIVACY_PANE_SEARCH_ENTRIES } from './privacy-search' import { SettingsSidebar } from './SettingsSidebar' import { SettingsSection } from './SettingsSection' import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' +import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' type SettingsNavTarget = | 'general' @@ -84,6 +90,7 @@ type SettingsNavTarget = | 'experimental' | 'agents' | 'orchestration' + | 'servers' | 'mobile' | 'repo' @@ -173,6 +180,7 @@ function isEditableTarget(target: EventTarget | null): boolean { function Settings(): React.JSX.Element { const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) + const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) const fetchSettings = useAppStore((s) => s.fetchSettings) const closeSettingsPage = useAppStore((s) => s.closeSettingsPage) const repos = useAppStore((s) => s.repos) @@ -355,7 +363,7 @@ function Settings(): React.JSX.Element { return [repo.id, { hasHooks: false, hooks: null, mayNeedUpdate: false }] as const } try { - const result = await window.api.hooks.check({ repoId: repo.id }) + const result = await checkRuntimeHooks(settings, repo.id) return [repo.id, result] as const } catch { return [repo.id, { hasHooks: false, hooks: null, mayNeedUpdate: false }] as const @@ -382,7 +390,7 @@ function Settings(): React.JSX.Element { return () => { stale = true } - }, [repos]) + }, [repos, settings]) const applyTheme = useCallback((theme: 'system' | 'dark' | 'light') => { applyDocumentTheme(theme) @@ -455,6 +463,14 @@ function Settings(): React.JSX.Element { icon: Network, searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES }, + { + id: 'servers', + title: 'Servers', + description: 'Run this client locally or through a remote Orca server.', + icon: Server, + searchEntries: [RUNTIME_ENVIRONMENTS_SEARCH_ENTRY], + badge: 'Beta' + }, { id: 'mobile', title: 'Mobile', @@ -522,7 +538,7 @@ function Settings(): React.JSX.Element { id: 'ssh', title: 'SSH', description: 'Remote SSH connections.', - icon: Server, + icon: Cable, searchEntries: SSH_PANE_SEARCH_ENTRIES }, { @@ -826,6 +842,19 @@ function Settings(): React.JSX.Element { + + + + ([]) + const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) const paths = repo.symlinkPaths ?? [] const queryTrimmed = query.trim().replace(/^\/+/, '') useEffect(() => { + if (activeRuntimeEnvironmentId?.trim()) { + setEntries([]) + return + } let cancelled = false void window.api.fs .readDir({ dirPath: repo.path, connectionId: repo.connectionId ?? undefined }) @@ -44,7 +50,7 @@ export function WorktreeSymlinksSection({ return () => { cancelled = true } - }, [repo.path, repo.connectionId]) + }, [activeRuntimeEnvironmentId, repo.path, repo.connectionId]) const filtered = useMemo(() => { const q = queryTrimmed.toLowerCase() diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index 5678c9300a6..d24aeda7e0f 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -13,6 +13,7 @@ import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/di import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo } from '../../../../shared/types' @@ -46,6 +47,12 @@ export function useCreateRepo( }, []) const handlePickParent = useCallback(async () => { + if (useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) { + // Why: the native folder picker returns a client-local path. Runtime + // project creation needs an explicit server parent path. + toast.error('Enter a server parent path.') + return + } const dir = await window.api.repos.pickDirectory() if (dir) { setCreateParent(dir) @@ -63,11 +70,25 @@ export function useCreateRepo( setIsCreating(true) setCreateError(null) try { - const result = await window.api.repos.create({ - parentPath, - name, - kind: createKind - }) + const settings = useAppStore.getState().settings + const target = getActiveRuntimeTarget(settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ repo: Repo } | { error: string }>( + target, + 'repo.create', + { + parentPath, + name, + kind: createKind + }, + { timeoutMs: 60_000 } + ) + : await window.api.repos.create({ + parentPath, + name, + kind: createKind + }) // Why: if the user closed the dialog or clicked Back mid-create, // createGenRef was bumped by resetCreateState. Ignore stale results. if (gen !== createGenRef.current) { @@ -147,6 +168,7 @@ export function useCreateRepo( createError, isCreating, setCreateName, + setCreateParent, setCreateKind, setCreateError, resetCreateState, @@ -235,7 +257,9 @@ type CreateStepProps = { createKind: RepoKind createError: string | null isCreating: boolean + manualParentEntry?: boolean onNameChange: (value: string) => void + onParentChange: (value: string) => void onKindChange: (kind: RepoKind) => void onPickParent: () => void onCreate: () => void @@ -247,7 +271,9 @@ export function CreateStep({ createKind, createError, isCreating, + manualParentEntry = false, onNameChange, + onParentChange, onKindChange, onPickParent, onCreate @@ -333,11 +359,21 @@ export function CreateStep({ />
- {/* Location. The "Choose…" button morphs into a summary + Change once picked. */} + {/* Location. The local flow uses a folder picker; runtime servers need + manual server-path entry because the client cannot browse that filesystem yet. */}
Location - {createParent ? ( + {manualParentEntry ? ( + onParentChange(e.target.value)} + placeholder="/home/user/projects" + className="h-11 text-sm font-mono" + disabled={isCreating} + spellCheck={false} + /> + ) : createParent ? (
diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 7f207bf6224..1a83b4b45de 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -11,12 +11,14 @@ import { DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { track } from '@/lib/telemetry' import { RemoteStep, CloneStep, useRemoteRepo } from './AddRepoSteps' import { CreateStep, useCreateRepo } from './AddRepoCreateStep' import { SetupStep } from './AddRepoSetupStep' import { getDefaultCloneParent } from './clone-defaults' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo, Worktree } from '../../../../shared/types' @@ -24,6 +26,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const activeModal = useAppStore((s) => s.activeModal) const closeModal = useAppStore((s) => s.closeModal) const addRepo = useAppStore((s) => s.addRepo) + const addRepoPath = useAppStore((s) => s.addRepoPath) const repos = useAppStore((s) => s.repos) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) @@ -35,6 +38,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'setup'>('add') const [addedRepo, setAddedRepo] = useState(null) const [isAdding, setIsAdding] = useState(false) + const [serverPath, setServerPath] = useState('') + const [isAddingServerPath, setIsAddingServerPath] = useState(false) const [cloneUrl, setCloneUrl] = useState('') const [cloneDestination, setCloneDestination] = useState('') const [isCloning, setIsCloning] = useState(false) @@ -71,6 +76,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { createError, isCreating, setCreateName, + setCreateParent, setCreateKind, setCreateError, resetCreateState, @@ -95,15 +101,19 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { if (cloneDestination) { return } + if (settings?.activeRuntimeEnvironmentId?.trim()) { + return + } if (!settings?.workspaceDir) { return } cloneStepAutoFilledRef.current = true setCloneDestination(getDefaultCloneParent(settings.workspaceDir)) - }, [step, cloneDestination, settings?.workspaceDir]) + }, [step, cloneDestination, settings?.activeRuntimeEnvironmentId, settings?.workspaceDir]) const isOpen = activeModal === 'add-repo' const repoId = addedRepo?.id ?? '' + const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) const worktrees = useMemo(() => { return worktreesByRepo[repoId] ?? [] @@ -127,6 +137,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setStep('add') setAddedRepo(null) setIsAdding(false) + setServerPath('') + setIsAddingServerPath(false) setCloneUrl('') setCloneDestination('') setIsCloning(false) @@ -162,13 +174,42 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { } }, [addRepo, fetchWorktrees, closeModal]) + const handleAddServerPath = useCallback( + async (kind: 'git' | 'folder') => { + const path = serverPath.trim() + if (!path) { + return + } + setIsAddingServerPath(true) + try { + const repo = await addRepoPath(path, kind) + if (repo && isGitRepoKind(repo)) { + setAddedRepo(repo) + await fetchWorktrees(repo.id) + setStep('setup') + } else if (repo) { + closeModal() + } + } finally { + setIsAddingServerPath(false) + } + }, + [addRepoPath, closeModal, fetchWorktrees, serverPath] + ) + const handlePickDestination = useCallback(async () => { + if (settings?.activeRuntimeEnvironmentId?.trim()) { + // Why: the native folder picker returns a client-local path. Runtime + // clone destinations must be typed as server paths. + toast.error('Enter a server path for the clone destination.') + return + } const dir = await window.api.repos.pickDirectory() if (dir) { setCloneDestination(dir) setCloneError(null) } - }, []) + }, [settings?.activeRuntimeEnvironmentId]) const handleClone = useCallback(async () => { const trimmedUrl = cloneUrl.trim() @@ -180,10 +221,24 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setCloneError(null) setCloneProgress(null) try { - const repo = (await window.api.repos.clone({ - url: trimmedUrl, - destination: cloneDestination.trim() - })) as Repo + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const repo = + target.kind === 'environment' + ? ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.clone', + { + url: trimmedUrl, + destination: cloneDestination.trim() + }, + { timeoutMs: 10 * 60_000 } + ) + ).repo + : ((await window.api.repos.clone({ + url: trimmedUrl, + destination: cloneDestination.trim() + })) as Repo) // Why: if the user closed the dialog or clicked Back during the clone, // cloneGenRef will have been bumped by resetState. Ignore this stale result. if (gen !== cloneGenRef.current) { @@ -310,7 +365,76 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
- {step === 'add' ? ( + {step === 'add' && isRuntimeEnvironmentActive ? ( + <> + + Add a server project + + Add a Git repository or folder that already exists on the selected runtime server. + + + +
+
+ + setServerPath(event.target.value)} + placeholder="/home/user/project" + className="h-11 text-sm font-mono" + disabled={isAddingServerPath} + autoFocus + spellCheck={false} + /> +
+
+ + +
+
+ + +
+
+ + ) : step === 'add' ? ( <> Add a project @@ -411,6 +535,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { cloneError={cloneError} cloneProgress={cloneProgress} isCloning={isCloning} + disableDestinationPicker={isRuntimeEnvironmentActive} onUrlChange={(value) => { setCloneUrl(value) setCloneError(null) @@ -429,10 +554,15 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { createKind={createKind} createError={createError} isCreating={isCreating} + manualParentEntry={isRuntimeEnvironmentActive} onNameChange={(value) => { setCreateName(value) setCreateError(null) }} + onParentChange={(value) => { + setCreateParent(value) + setCreateError(null) + }} onKindChange={(kind) => { setCreateKind(kind) setCreateError(null) diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index a40c23c1400..aaf6eb78aee 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -306,6 +306,7 @@ type CloneStepProps = { cloneError: string | null cloneProgress: { phase: string; percent: number } | null isCloning: boolean + disableDestinationPicker?: boolean onUrlChange: (value: string) => void onDestChange: (value: string) => void onPickDestination: () => void @@ -318,6 +319,7 @@ export function CloneStep({ cloneError, cloneProgress, isCloning, + disableDestinationPicker = false, onUrlChange, onDestChange, onPickDestination, @@ -369,7 +371,8 @@ export function CloneStep({ size="sm" className="h-8 px-2 shrink-0" onClick={onPickDestination} - disabled={isCloning} + disabled={isCloning || disableDestinationPicker} + title={disableDestinationPicker ? 'Enter a server path manually' : 'Choose folder'} > diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index e9f6a69d4f1..646506caf78 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useCallback, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { Badge } from '@/components/ui/badge' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { @@ -74,6 +75,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const openModal = useAppStore((s) => s.openModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) + const settings = useAppStore((s) => s.settings) const fetchIssue = useAppStore((s) => s.fetchIssue) const cardProps = useAppStore((s) => s.worktreeCardProperties) const handleEditIssue = useCallback( @@ -175,7 +177,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ const branch = branchDisplayName(worktree.branch) const isFolder = repo ? isFolderRepo(repo) : false - const hostedReviewCacheKey = repo && branch ? `${repo.path}::${branch}` : '' + const hostedReviewCacheKey = + repo && branch ? getHostedReviewCacheKey(repo.path, branch, settings) : '' const issueCacheKey = repo && worktree.linkedIssue ? `${repo.path}::${worktree.linkedIssue}` : '' // Subscribe to ONLY the specific cache entry, not entire review/issue caches. diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 59ac54b5245..31b9f77245b 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -27,6 +27,7 @@ import type { Worktree } from '../../../../shared/types' import { isFolderRepo } from '../../../../shared/repo-kind' import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' import { runSleepWorktrees } from './sleep-worktree-flow' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' type Props = { worktree: Worktree @@ -100,8 +101,16 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ }, []) const handleOpenInFinder = useCallback(() => { + if ( + isLocalPathOpenBlocked(useAppStore.getState().settings, { + connectionId: repo?.connectionId ?? null + }) + ) { + showLocalPathOpenBlockedToast() + return + } window.api.shell.openPath(worktree.path) - }, [worktree.path]) + }, [repo?.connectionId, worktree.path]) const handleCopyPath = useCallback(() => { window.api.ui.writeClipboardText(worktree.path) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index a58b538d123..c423a029b69 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -45,6 +45,7 @@ import { pruneWorktreeSelection, updateWorktreeSelection } from './worktree-multi-selection' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' // How long to wait after a sortEpoch bump before actually re-sorting. // Prevents jarring position shifts when background events (AI starting work, @@ -820,7 +821,15 @@ const WorktreeList = React.memo(function WorktreeList() { if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadPty.current) { return } - void window.api.worktrees.persistSortOrder({ orderedIds: sortedIds }) + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + void (target.kind === 'environment' + ? callRuntimeRpc( + target, + 'worktree.persistSortOrder', + { orderedIds: sortedIds }, + { timeoutMs: 15_000 } + ) + : window.api.worktrees.persistSortOrder({ orderedIds: sortedIds })) }, [sortedIds, sortBy]) // Flatten, filter, and apply stable sort order via the shared utility so diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 8a09b74d6df..bf6ae9c7993 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -657,6 +657,10 @@ export function ResourceUsageStatusSegment({ const setActiveView = useAppStore((s) => s.setActiveView) const openSpacePage = useAppStore((s) => s.openSpacePage) const repos = useAppStore((s) => s.repos) + const activeRuntimeEnvironmentId = useAppStore( + (s) => s.settings?.activeRuntimeEnvironmentId ?? null + ) + const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim()) const [open, setOpen] = useState(false) const [sortOption, setSortOption] = useState('memory') @@ -667,6 +671,10 @@ export function ResourceUsageStatusSegment({ const [sessionsError, setSessionsError] = useState(false) const [killConfirm, setKillConfirm] = useState(null) const [killing, setKilling] = useState(false) + // Why: this segment only understands the local Electron PTY/resource daemon. + // While a runtime server is active, hiding local samples avoids showing or + // killing sessions from the wrong machine. + const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot // Why: after a kill confirms and the session unmounts, focus would otherwise // fall to . We park a ref on the popover body so we can restore focus @@ -674,6 +682,11 @@ export function ResourceUsageStatusSegment({ const popoverBodyRef = useRef(null) const refreshSessions = useCallback(async () => { + if (runtimeEnvironmentActive) { + setSessions([]) + setSessionsError(false) + return + } try { const result = await window.api.pty.listSessions() setSessions(result) @@ -681,7 +694,7 @@ export function ResourceUsageStatusSegment({ } catch { setSessionsError(true) } - }, []) + }, [runtimeEnvironmentActive]) const daemonActions = useDaemonActions({ onRestartSettled: () => { @@ -698,7 +711,7 @@ export function ResourceUsageStatusSegment({ // background at a slower rate so the badge count stays reasonably fresh // without keeping the Memory IPC hot. useEffect(() => { - if (!open) { + if (!open || runtimeEnvironmentActive) { return } void fetchSnapshot() @@ -713,9 +726,14 @@ export function ResourceUsageStatusSegment({ return () => { window.clearInterval(memTimer) } - }, [open, fetchSnapshot, refreshSessions]) + }, [open, runtimeEnvironmentActive, fetchSnapshot, refreshSessions]) useEffect(() => { + if (runtimeEnvironmentActive) { + setSessions([]) + setSessionsError(false) + return + } const refreshIfVisible = (): void => { if (document.visibilityState === 'visible' && document.hasFocus()) { void refreshSessions() @@ -733,7 +751,7 @@ export function ResourceUsageStatusSegment({ window.removeEventListener('focus', refreshIfVisible) document.removeEventListener('visibilitychange', refreshIfVisible) } - }, [refreshSessions]) + }, [runtimeEnvironmentActive, refreshSessions]) const repoDisplayNameById = useMemo(() => { const map = new Map() @@ -766,8 +784,8 @@ export function ResourceUsageStatusSegment({ // feel laggy because the segment is always mounted in the status bar. const unifiedRepos = useMemo( () => - open - ? mergeSnapshotAndSessions(snapshot, sessions, { + open && !runtimeEnvironmentActive + ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { tabsByWorktree, ptyIdsByTabId, runtimePaneTitlesByTabId, @@ -778,7 +796,8 @@ export function ResourceUsageStatusSegment({ : [], [ open, - snapshot, + runtimeEnvironmentActive, + resourceSnapshot, sessions, tabsByWorktree, ptyIdsByTabId, @@ -794,7 +813,7 @@ export function ResourceUsageStatusSegment({ // Build the bound set with a single flat walk instead of nested Object // iterations to keep this light on every store update. const orphanCount = useMemo(() => { - if (!workspaceSessionReady) { + if (!workspaceSessionReady || runtimeEnvironmentActive) { return 0 } const bound = new Set() @@ -812,32 +831,36 @@ export function ResourceUsageStatusSegment({ } } return n - }, [sessions, ptyIdsByTabId, workspaceSessionReady]) + }, [sessions, ptyIdsByTabId, workspaceSessionReady, runtimeEnvironmentActive]) const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { - const memory = snapshot?.totalMemory ?? 0 - const cpu = snapshot?.totalCpu ?? 0 - const hostTotal = snapshot?.host.totalMemory ?? 0 + const memory = resourceSnapshot?.totalMemory ?? 0 + const cpu = resourceSnapshot?.totalCpu ?? 0 + const hostTotal = resourceSnapshot?.host.totalMemory ?? 0 return { totalMemory: memory, totalCpu: cpu, hostShare: hostTotal > 0 ? (memory / hostTotal) * 100 : 0, - memBadgeLabel: snapshot ? formatMemory(memory) : '—' + memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—' } - }, [snapshot]) + }, [resourceSnapshot]) // Why: memorySnapshotError is null both for "last fetch succeeded" and // "never fetched". When the segment is mounted but the popover hasn't // been opened, fetchMemorySnapshot has never run, so a sessions IPC // failure on the always-on poll would otherwise be silent. Treat the // absence of any snapshot plus a sessions error as unreachable too. - const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null) + const daemonUnreachable = + !runtimeEnvironmentActive && + sessionsError && + (memorySnapshotError !== null || snapshot === null) // Why: a partial failure where the sessions IPC fails but the snapshot // IPC still works was silently invisible after the merge — the old // SessionsTabPanel surfaced it as "Terminal sessions unavailable". Show // a slim inline notice so the user understands why the session list is // empty/stale even though the resource numbers look fine. - const sessionsOnlyError = sessionsError && memorySnapshotError === null + const sessionsOnlyError = + !runtimeEnvironmentActive && sessionsError && memorySnapshotError === null const toggleRepo = useCallback((repoId: string): void => { setCollapsedRepos((prev) => { @@ -1060,7 +1083,7 @@ export function ResourceUsageStatusSegment({ - Restart daemon + {runtimeEnvironmentActive ? 'Unavailable for runtime servers' : 'Restart daemon'} @@ -1076,7 +1099,7 @@ export function ResourceUsageStatusSegment({ - Kill all sessions + {runtimeEnvironmentActive ? 'Unavailable for runtime servers' : 'Kill all sessions'}
@@ -1122,7 +1145,7 @@ export function ResourceUsageStatusSegment({
)} - {snapshot && ( + {resourceSnapshot && (
@@ -1182,7 +1205,7 @@ export function ResourceUsageStatusSegment({ inner tree owns its own scroll. The footer renders below this shell when orphan-bulk-kill is available. */}
- {(unifiedRepos.length > 0 || snapshot) && ( + {(unifiedRepos.length > 0 || resourceSnapshot) && (
@@ -1346,7 +1373,7 @@ export function ResourceUsageStatusSegment({ - + {!runtimeEnvironmentActive && } ) } diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index a33cc025318..1e24243f01b 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -22,7 +22,7 @@ import { basename, normalizeRelativePath } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' import { detectLanguage } from '@/lib/language-detect' -import { useWorktreeById } from '@/store/selectors' +import { useRepoById, useWorktreeById } from '@/store/selectors' import { useAppStore } from '@/store' import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display' import type { GitFileStatus } from '../../../../shared/types' @@ -35,6 +35,8 @@ import { type DropIndicator } from './drop-indicator' import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls' +import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' +import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') @@ -74,6 +76,7 @@ export default function EditorFileTab({ dropIndicator?: DropIndicator }): React.JSX.Element { const worktree = useWorktreeById(file.worktreeId) + const repo = useRepoById(worktree?.repoId ?? null) // Why: no transform/transition/isDragging styling — the drag design is // that tabs stay visually anchored; only the blue insertion bar moves. const { attributes, listeners, setNodeRef } = useSortable({ @@ -379,6 +382,7 @@ export default function EditorFileTab({ filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, language: resolvedLanguage }) }} @@ -407,6 +411,16 @@ export default function EditorFileTab({ { + if ( + shouldBlockEditorTabLocalOpen( + useAppStore.getState().settings, + file.runtimeEnvironmentId, + repo?.connectionId ?? null + ) + ) { + showLocalPathOpenBlockedToast() + return + } window.api.shell.openPath(file.filePath) }} > diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 7f15947cfc6..91eef724ba0 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -38,6 +38,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { listRuntimeProjectNotes } from '@/runtime/runtime-notes-client' import type { NoteSummary } from '../../../../shared/notes-types' const isMac = navigator.userAgent.includes('Mac') @@ -173,6 +174,7 @@ function TabBarInner({ const defaultWindowsPowerShellImplementation = useAppStore( (s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto' ) + const settings = useAppStore((s) => s.settings) const [pwshAvailable, setPwshAvailable] = useState(false) const [projectNotes, setProjectNotes] = useState([]) const [projectNotesLoading, setProjectNotesLoading] = useState(false) @@ -237,7 +239,7 @@ function TabBarInner({ setProjectNotesLoading(true) setProjectNotesError(null) try { - const result = await window.api.notes.list({ + const result = await listRuntimeProjectNotes(settings, { projectId: context.projectId, worktreeId: context.worktreeId, limit: 100 @@ -250,7 +252,7 @@ function TabBarInner({ } } void refreshProjectNotes() - }, [newTabMenuOpen, onNewNotesTab, targetNotesWorktreeId]) + }, [newTabMenuOpen, onNewNotesTab, settings, targetNotesWorktreeId]) const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs]) const editorMap = useMemo( diff --git a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts new file mode 100644 index 00000000000..21b850b21fa --- /dev/null +++ b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' + +describe('shouldBlockEditorTabLocalOpen', () => { + it('blocks remote-owned editor tabs even after the active runtime switches local', () => { + expect(shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, 'env-1', null)).toBe( + true + ) + }) + + it('blocks SSH editor tabs without a runtime owner', () => { + expect( + shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, undefined, 'ssh-1') + ).toBe(true) + }) + + it('allows local editor tabs without runtime or SSH ownership', () => { + expect( + shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, undefined, null) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts new file mode 100644 index 00000000000..37302887f6d --- /dev/null +++ b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts @@ -0,0 +1,13 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { isLocalPathOpenBlocked } from '@/lib/local-path-open-guard' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' + +export function shouldBlockEditorTabLocalOpen( + settings: Pick | null | undefined, + fileRuntimeEnvironmentId: string | null | undefined, + connectionId: string | null | undefined +): boolean { + return isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, fileRuntimeEnvironmentId), { + connectionId + }) +} diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index dabf845257f..593653ef4a2 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -16,6 +16,7 @@ import { requestEditorFileClose } from '../editor/editor-autosave' import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface' import { getProjectNotesEntityId } from '../../lib/open-project-notes-tab' import { requestProjectNotesTabClose } from '../../lib/project-notes-close-request' +import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client' export type GroupEditorItem = OpenFile & { tabId: string } export type GroupBrowserItem = BrowserTabState & { tabId: string } @@ -484,7 +485,8 @@ export function useTabGroupWorkspaceModel({ closeToRight, createSplitGroup, newBrowserTab: () => { - const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' + const state = useAppStore.getState() + const defaultUrl = state.browserDefaultUrl ?? 'about:blank' createBrowserTab(worktreeId, defaultUrl, { title: 'New Browser Tab', focusAddressBar: true @@ -513,7 +515,13 @@ export function useTabGroupWorkspaceModel({ } try { const connectionId = getConnectionId(worktreeId) ?? undefined - const fileInfo = await createUntitledMarkdownFile(path, worktreeId, connectionId) + const settings = useAppStore.getState().settings + const fileInfo = await createUntitledMarkdownFile( + path, + worktreeId, + connectionId, + settings + ) openFile(fileInfo, { preview: false, targetGroupId: groupId }) } catch (err) { toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.')) @@ -524,9 +532,19 @@ export function useTabGroupWorkspaceModel({ let label = 'Project Notes' if (noteId) { try { - const result = await window.api.notes.show({ projectId, worktreeId, note: noteId }) + const settings = useAppStore.getState().settings + const result = await showRuntimeProjectNote(settings, { + projectId, + worktreeId, + note: noteId + }) label = result.note.title - await window.api.notes.link({ projectId, worktreeId, note: noteId, kind: 'active' }) + await linkRuntimeProjectNote(settings, { + projectId, + worktreeId, + note: noteId, + kind: 'active' + }) } catch { label = 'Project Notes' } diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 3a3469b471a..909d1f91977 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -14,6 +14,7 @@ import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import { fitPanes, isWindowsUserAgent, shellEscapePath } from './pane-helpers' import { getConnectionId } from '@/lib/connection-context' +import { resolveTerminalDropTargetShell } from './terminal-drop-handler' import { EMPTY_LAYOUT, paneLeafId, serializeTerminalLayout } from './layout-serialization' import { applyExpandedLayoutTo, @@ -42,6 +43,12 @@ import { import { getDriverForPty, onDriverChange } from '@/lib/pane-manager/mobile-driver-state' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle +} from '@/runtime/runtime-terminal-stream' // Why: registry lives in a leaf module so the store slice can import it // without re-entering the `slice → TerminalPane → store → slice` cycle @@ -452,10 +459,10 @@ export default function TerminalPane({ executeClosePane(paneId) return } - void window.api.pty - .hasChildProcesses(ptyId) - .then((hasChildren) => { - if (hasChildren) { + const settings = useAppStore.getState().settings + void inspectRuntimeTerminalProcess(settings, ptyId) + .then((process) => { + if (process.hasChildProcesses) { setCloseConfirmPaneId(paneId) } else { executeClosePane(paneId) @@ -1081,20 +1088,21 @@ export default function TerminalPane({ if (!transport) { return } - // Why: the explorer passes the worktree-absolute path via a DOM - // MIME, so for SSH worktrees this is a remote POSIX path destined - // for the remote shell. Quote for the target shell (remote = posix) - // rather than the client OS; otherwise a Windows client dropping - // onto an SSH-Linux worktree would emit Windows-style quoting. - // Why: `typeof === 'string'` (not `!== null`) so an unhydrated - // store (`undefined`) is treated as local and falls through to - // client-OS quoting, rather than being misclassified as remote. - const isRemote = typeof getConnectionId(worktreeId) === 'string' - const targetShell: 'posix' | 'windows' = isRemote - ? 'posix' - : isWindowsUserAgent() - ? 'windows' - : 'posix' + const state = useAppStore.getState() + const worktreePath = + Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((worktree) => worktree.id === worktreeId)?.path ?? + cwd ?? + filePath + const targetShell = resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId: state.settings?.activeRuntimeEnvironmentId, + worktreePath, + // Why: internal Explorer drags paste a worktree-absolute path + // directly into the target shell. Runtime drops use the runtime + // worktree's path shape; legacy SSH drops remain POSIX. + connectionId: getConnectionId(worktreeId) + }) transport.sendInput(shellEscapePath(filePath, targetShell)) // Move focus to the terminal so the user can keep typing where the // dropped path just landed. Without this, focus stays on the file @@ -1256,7 +1264,21 @@ export default function TerminalPane({ // active-mobile-subscriber path and the held-no-subscriber // path (docs/mobile-fit-hold.md), so the banner unmounts // and the PTY returns to desktop dims in either case. - void window.api.runtime.restoreTerminalFit(ptyId) + const remoteHandle = getRemoteRuntimeTerminalHandle(ptyId) + const environmentId = + getRemoteRuntimePtyEnvironmentId(ptyId) ?? + settingsRef.current?.activeRuntimeEnvironmentId ?? + null + if (remoteHandle && environmentId) { + void callRuntimeRpc( + { kind: 'environment', environmentId }, + 'terminal.restoreFit', + { terminal: remoteHandle }, + { timeoutMs: 15_000 } + ).catch(() => {}) + } else { + void window.api.runtime.restoreTerminalFit(ptyId).catch(() => {}) + } } }} > diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 518964f6274..ffc8cf5c70d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -24,7 +24,7 @@ type StoreState = { repos: { id: string; connectionId?: string | null }[] sshConnectionStates: Map cacheTimerByKey: Record - settings: { promptCacheTimerEnabled?: boolean } | null + settings: { promptCacheTimerEnabled?: boolean; activeRuntimeEnvironmentId?: string | null } | null codexRestartNoticeByPtyId: Record< string, { previousAccountLabel: string; nextAccountLabel: string } @@ -138,6 +138,19 @@ vi.mock('./pty-transport', () => ({ }) })) +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + function createMockTransport(initialPtyId: string | null = null): MockTransport { let ptyId = initialPtyId return { @@ -957,6 +970,65 @@ describe('connectPanePty', () => { expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-local-detached') }) + it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-1' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(transport.connect).not.toHaveBeenCalled() + expect(transport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: 'remote:terminal-1' }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:terminal-1') + }) + + it('constructs restored encoded remote PTYs with their owning runtime environment', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-2' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + expect(transport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: 'remote:env-1@@terminal-1' }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') + }) + it('persists a restarted pane PTY id and uses it on the next remount', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index eac55170eba..544422456ca 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -6,6 +6,7 @@ import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import type { PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' +import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' import type { PtyConnectionDeps } from './pty-connection-types' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' @@ -20,6 +21,7 @@ import { } from './layout-serialization' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { discardTerminalOutput, flushTerminalOutput, @@ -30,6 +32,7 @@ import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' +const REMOTE_PTY_ID_PREFIX = 'remote:' // Why: when multiple panes/tabs need the same deferred SSH connection, // the first one calls ssh.connect() and subsequent ones must wait for it @@ -48,6 +51,10 @@ function formatSshSessionExpiredMessage(): string { return 'Previous SSH session expired. Start a new terminal to continue.' } +function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX) +} + function sshPromptConnectOutcomeForStatus( status: string | undefined, sawNonDisconnected: boolean @@ -340,7 +347,17 @@ export function connectPanePty( const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId) const shellOverride = tab?.shellOverride - const transport = createIpcPtyTransport({ + const restoredPtyIdForTransport = + deps.restoredLeafId && deps.restoredPtyIdByLeafId + ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null) + : null + const remoteRuntimeOwnerForTransport = + (restoredPtyIdForTransport + ? getRemoteRuntimePtyEnvironmentId(restoredPtyIdForTransport) + : null) ?? (tab?.ptyId ? getRemoteRuntimePtyEnvironmentId(tab.ptyId) : null) + const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = remoteRuntimeOwnerForTransport ?? activeRuntimeEnvironmentId + const transportOptions = { cwd: deps.cwd, env: paneEnv, command: paneStartup?.command, @@ -374,7 +391,10 @@ export function connectPanePty( const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] currentState.setAgentStatus(cacheKey, payload, title) } - }) + } + const transport = runtimeEnvironmentId + ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) + : createIpcPtyTransport(transportOptions) const hasExistingPaneTransport = deps.paneTransportsRef.current.size > 0 deps.paneTransportsRef.current.set(pane.id, transport) @@ -466,7 +486,9 @@ export function connectPanePty( if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) { return } - window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows) + if (!isRemoteRuntimePtyId(currentPtyId)) { + window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows) + } } const geometryReportObserver = typeof ResizeObserver === 'undefined' @@ -580,9 +602,9 @@ export function connectPanePty( // calls from the same renderer. The cooperation gate at pty:spawn time // sees pendingByPaneKey populated. Settle/clear later echoes the gen // token captured here. See docs/mobile-prefer-renderer-scrollback.md. - const preSignalPromise = window.api.pty - .declarePendingPaneSerializer(cacheKey) - .catch(() => null) + const preSignalPromise = runtimeEnvironmentId + ? Promise.resolve(null) + : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) const spawnedRaw = transport.connect({ url: '', @@ -601,8 +623,10 @@ export function connectPanePty( typeof spawnedPtyId === 'string' ? spawnedPtyId : transport.getPtyId() const gen = await preSignalPromise if (typeof gen === 'number' && resolvedPtyId) { - registerPaneSerializerFor(resolvedPtyId) - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + if (!isRemoteRuntimePtyId(resolvedPtyId)) { + registerPaneSerializerFor(resolvedPtyId) + void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + } } else if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } @@ -742,7 +766,9 @@ export function connectPanePty( if (connectResult.coldRestore) { // Snapshot superseded the cold-restore payload — ack it so the // daemon does not redeliver it on the next reattach. - window.api.pty.ackColdRestore(ptyId) + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } } } else if (connectResult?.replay) { // Relay replay holds the last 100 KB of raw output. The xterm may @@ -753,7 +779,9 @@ export function connectPanePty( writeReplayData(connectResult.replay) writeReplayData(POST_REPLAY_FOCUS_REPORTING_RESET) if (connectResult.coldRestore) { - window.api.pty.ackColdRestore(ptyId) + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } } } else if (connectResult?.coldRestore) { // restoreScrollbackBuffers() already wrote the saved xterm buffer @@ -771,7 +799,9 @@ export function connectPanePty( // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so // reset them to match the fresh shell's expectations. writeReplayData(POST_REPLAY_MODE_RESET) - window.api.pty.ackColdRestore(ptyId) + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } } // Why: when a mobile-fit override is active, skip sending desktop dims // to the PTY — the PTY is already at phone dimensions and must stay there. @@ -782,7 +812,9 @@ export function connectPanePty( // Why: POSIX only delivers SIGWINCH when terminal dimensions actually // change. Sending it explicitly guarantees restored TUIs repaint at // the correct cursor position after snapshot replay. - window.api.pty.signal(ptyId, 'SIGWINCH') + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.signal(ptyId, 'SIGWINCH') + } scheduleRuntimeGraphSync() } @@ -946,9 +978,10 @@ export function connectPanePty( // cooperation gate uniformly applies to remote sessions. Issue // declare and connect back-to-back; Electron preserves order. See // docs/mobile-prefer-renderer-scrollback.md. - const preSignalPromise = window.api.pty - .declarePendingPaneSerializer(cacheKey) - .catch(() => null) + const preSignalPromise = + runtimeEnvironmentId || isRemoteRuntimePtyId(pendingSessionId) + ? Promise.resolve(null) + : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) let expiredReattachError = false const reattachPromise = transport.connect({ url: '', @@ -991,7 +1024,9 @@ export function connectPanePty( handleReattachResult(result, pendingSessionId) const gen = await preSignalPromise if (typeof gen === 'number') { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + if (!isRemoteRuntimePtyId(pendingSessionId)) { + void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + } } }) .catch(async (err) => { @@ -1051,6 +1086,7 @@ export function connectPanePty( // the reattach and spawn a fresh session instead. const deferredReattachSessionId = candidateReattachSessionId && + !isRemoteRuntimePtyId(candidateReattachSessionId) && isSessionOwnedByWorktree(candidateReattachSessionId, deps.worktreeId) ? candidateReattachSessionId : null @@ -1074,9 +1110,10 @@ export function connectPanePty( // channel preserves order. See // docs/mobile-prefer-renderer-scrollback.md (Renderer-side prerequisite // requirement #4). - const preSignalPromise = window.api.pty - .declarePendingPaneSerializer(cacheKey) - .catch(() => null) + const preSignalPromise = + runtimeEnvironmentId || isRemoteRuntimePtyId(deferredReattachSessionId) + ? Promise.resolve(null) + : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) let expiredReattachError = false const reattachPromise = transport.connect({ @@ -1112,7 +1149,9 @@ export function connectPanePty( handleReattachResult(result, deferredReattachSessionId) const gen = await preSignalPromise if (typeof gen === 'number') { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { + void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + } } }) .catch(async (err) => { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index dd6c0737429..252c2f018ba 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1,5 +1,13 @@ /* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../shared/terminal-stream-protocol' describe('createIpcPtyTransport', () => { const originalWindow = (globalThis as { window?: typeof window }).window @@ -553,3 +561,215 @@ describe('createIpcPtyTransport', () => { expect(transport.getPtyId()).toBeNull() }) }) + +describe('createRemoteRuntimePtyTransport', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + let unsubscribe: { + unsubscribe: () => void + sendBinary: (bytes: Uint8Array) => void + } | null = null + let unsubscribeFn: ReturnType void>> | null = null + + beforeEach(() => { + vi.resetModules() + runtimeCall.mockReset() + runtimeSubscribe.mockReset() + subscriptionCallbacks = null + unsubscribeFn = vi.fn<() => void>() + unsubscribe = { + unsubscribe: unsubscribeFn, + sendBinary: vi.fn() + } + runtimeCall.mockResolvedValue({ + id: 'rpc-create', + ok: true, + result: { + terminal: { + handle: 'term-remote', + worktreeId: 'repo1::/remote/wt', + title: null, + surface: 'background' + } + }, + _meta: { runtimeId: 'runtime-remote' } + }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(() => { + subscriptionCallbacks?.onResponse({ + id: 'rpc-multiplex', + ok: true, + result: { type: 'ready' }, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + return unsubscribe + } + ) + + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + runtimeEnvironments: { + ...originalWindow?.api?.runtimeEnvironments, + call: runtimeCall, + subscribe: runtimeSubscribe + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + function latestRemoteSubscribePayload(): { streamId: number } { + const send = unsubscribe?.sendBinary as unknown as + | { mock: { calls: [Uint8Array][] } } + | undefined + const frames = + send?.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) ?? [] + const frame = frames.at(-1) + if (!frame) { + throw new Error('missing remote terminal subscribe frame') + } + const payload = decodeTerminalStreamJson<{ streamId: number }>(frame.payload) + if (!payload) { + throw new Error('invalid remote terminal subscribe frame') + } + return payload + } + + it('creates and subscribes to a terminal on the active remote runtime', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const onData = vi.fn() + const onConnect = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'repo1::/remote/wt', + command: 'claude', + env: { ORCA_TAB_ID: 'tab-1' }, + tabId: 'tab-1', + leafId: 'pane:1' + }) + + const result = await transport.connect({ + url: '', + callbacks: { onReplayData, onData, onConnect } + }) + + expect(result).toEqual({ id: 'remote:env-1@@term-remote', replay: '' }) + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.create', + params: { + worktree: 'repo1::/remote/wt', + command: 'claude', + env: { ORCA_TAB_ID: 'tab-1' }, + focus: false + }, + timeoutMs: 15_000 + }) + expect(runtimeSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex', + params: {} + }), + expect.any(Object) + ) + const { streamId } = latestRemoteSubscribePayload() + + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson({ kind: 'scrollback' }) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText('hello') + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array() + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: 4, + payload: encodeTerminalStreamText(' world') + }) + ) + + expect(onReplayData).toHaveBeenCalledWith('hello') + expect(onConnect).toHaveBeenCalled() + expect(onData).toHaveBeenCalledWith(' world') + }) + + it('forwards input and cleanup through runtime RPC', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'repo1::/remote/wt', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + const { streamId } = latestRemoteSubscribePayload() + runtimeCall.mockClear() + const send = unsubscribe?.sendBinary as unknown as { + mockClear: () => void + mock: { calls: [Uint8Array][] } + } + send.mockClear() + + expect(transport.sendInput('ls\r')).toBe(true) + await vi.runOnlyPendingTimersAsync() + expect(runtimeCall).not.toHaveBeenCalled() + const inputFrame = decodeTerminalStreamFrame(send.mock.calls[0][0]) + expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input) + expect(inputFrame?.streamId).toBe(streamId) + + transport.disconnect() + expect(unsubscribeFn).toHaveBeenCalled() + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'term-remote' }, + timeoutMs: 15_000 + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 98aeff0354c..8ccc6e8a300 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -38,10 +38,165 @@ export type { export { extractLastOscTitle } from '../../../../shared/agent-detection' const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' +const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared // Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher // so the OSC 9999 status payloads can be forwarded to the store. +type PtyOutputCallbacks = Parameters[0]['callbacks'] + +type PtyOutputProcessorOptions = Pick< + IpcPtyTransportOptions, + | 'onTitleChange' + | 'onBell' + | 'onAgentBecameIdle' + | 'onAgentBecameWorking' + | 'onAgentExited' + | 'onAgentStatus' +> + +type ProcessPtyOutputOptions = { + replayingBufferedData?: boolean + suppressAttentionEvents?: boolean +} + +export function createPtyOutputProcessor({ + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onAgentStatus +}: PtyOutputProcessorOptions): { + processData: ( + data: string, + callbacks: PtyOutputCallbacks, + options?: ProcessPtyOutputOptions + ) => void + clearAccumulatedState: () => void + clearStaleTitleTimer: () => void + resetBellDetector: () => void +} { + const bellDetector = createBellDetector() + const processAgentStatusChunk = createAgentStatusOscProcessor() + let lastEmittedTitle: string | null = null + let staleTitleTimer: ReturnType | null = null + const agentTracker = + onAgentBecameIdle || onAgentBecameWorking || onAgentExited + ? createAgentStatusTracker( + (title) => { + onAgentBecameIdle?.(title) + }, + onAgentBecameWorking, + onAgentExited + ) + : null + + function applyObservedTerminalTitle(title: string): void { + // Why: cursor-agent's native OSC title is the literal string "Cursor Agent" + // and it re-emits that title many times per turn (on every internal redraw) + // even while it's actively working. Orca drives the cursor spinner/unread + // path by injecting its own synthesized "⠋ Cursor Agent" and "Cursor ready" + // frames from the hook server (see src/main/index.ts). If we let cursor's + // bare title through, it lands in `runtimePaneTitlesByTabId` — where + // `getWorktreeStatus` reads from — and flips the sidebar dot back to solid + // within a second of the spinner appearing. Dropping the bare title before + // it reaches the store leaves the synthesized frame as the last-applied + // state until the next hook event overwrites it. Match is literal (trimmed, + // case-insensitive) so any task/chat title cursor auto-generates still + // passes through unchanged. + if (title.trim().toLowerCase() === 'cursor agent') { + return + } + lastEmittedTitle = normalizeTerminalTitle(title) + onTitleChange?.(lastEmittedTitle, title) + agentTracker?.handleTitle(title) + } + + function clearStaleTitleTimer(): void { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } + } + + function processData( + data: string, + callbacks: PtyOutputCallbacks, + options: ProcessPtyOutputOptions = {} + ): void { + const suppressAttentionEvents = options.suppressAttentionEvents === true + // Why: OSC 9999 is a renderer-only control protocol. Parse it before + // xterm sees the bytes, and keep parser state across chunks so partial + // PTY reads do not drop valid status updates or print escape garbage. + const processed = processAgentStatusChunk(data) + data = processed.cleanData + // Why: mirror the onBell / onAgentBecameIdle guard below — during eager-buffer + // replay we must not surface stale agent-status payloads from a prior app + // session into the live store. The parser still consumes the bytes so they + // do not leak into xterm, we just suppress the callback. + if (onAgentStatus && !suppressAttentionEvents) { + for (const payload of processed.payloads) { + onAgentStatus(payload) + } + } + if (options.replayingBufferedData && callbacks.onReplayData) { + callbacks.onReplayData(data) + } else { + callbacks.onData?.(data) + } + if (onTitleChange) { + // Why: feed EVERY OSC title in the chunk through the observer, not just + // the last one. node-pty + the main-process 8ms batch window commonly + // coalesce multiple title updates into a single IPC payload — for Pi's + // 80ms spinner + agent_end idle cycle, the last title in the chunk is + // the idle one and the intermediate working frames were silently + // dropped, so the worktree card never observed the working state. + // Processing titles in order preserves the working→idle transition + // that detectAgentStatusFromTitle and agentTracker both key off. + const titles = extractAllOscTitles(data) + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTerminalTitle(title) + } + } else if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { + clearStaleTitleTimer() + staleTitleTimer = setTimeout(() => { + staleTitleTimer = null + if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { + const cleared = clearWorkingIndicators(lastEmittedTitle) + lastEmittedTitle = cleared + onTitleChange(cleared, cleared) + agentTracker?.handleTitle(cleared) + } + }, STALE_TITLE_TIMEOUT) + } + } + // Why: BEL is the attention signal. The detector is stateful across + // chunks so a BEL sitting inside an OSC sequence (e.g. Claude's + // `\e]0;title\a`) is correctly ignored — only true terminal bells raise + // attention. suppressAttentionEvents gates this during eager-buffer replay + // so historical BELs do not produce fresh alerts on cold reattach. + if (onBell && bellDetector.chunkContainsBell(data) && !suppressAttentionEvents) { + onBell() + } + } + + function clearAccumulatedState(): void { + clearStaleTitleTimer() + agentTracker?.reset() + bellDetector.reset() + } + + return { + processData, + clearAccumulatedState, + clearStaleTitleTimer, + resetBellDetector: () => bellDetector.reset() + } +} + export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTransport { const { cwd, @@ -65,30 +220,24 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra let connected = false let destroyed = false let ptyId: string | null = null - const bellDetector = createBellDetector() // Why: eager PTY buffers contain output produced before the pane attached — // often from the previous app session. We still replay that data so titles // and scrollback restore correctly, but it must not produce fresh bells, // unread marks, or notifications for unrelated worktrees just because Orca // is reconnecting background terminals on launch. let suppressAttentionEvents = false - const processAgentStatusChunk = createAgentStatusOscProcessor() - let lastEmittedTitle: string | null = null - let staleTitleTimer: ReturnType | null = null - const agentTracker = - onAgentBecameIdle || onAgentBecameWorking || onAgentExited - ? createAgentStatusTracker( - (title) => { - if (!suppressAttentionEvents) { - onAgentBecameIdle?.(title) - } - }, - onAgentBecameWorking, - onAgentExited - ) - : null - - const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared + const outputProcessor = createPtyOutputProcessor({ + onTitleChange, + onBell, + onAgentBecameIdle: (title) => { + if (!suppressAttentionEvents) { + onAgentBecameIdle?.(title) + } + }, + onAgentBecameWorking, + onAgentExited, + onAgentStatus + }) let storedCallbacks: Parameters[0]['callbacks'] = {} function unregisterPtyHandlers(id: string): void { @@ -103,27 +252,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ptyReplayHandlers.delete(id) } - function applyObservedTerminalTitle(title: string): void { - // Why: cursor-agent's native OSC title is the literal string "Cursor Agent" - // and it re-emits that title many times per turn (on every internal redraw) - // even while it's actively working. Orca drives the cursor spinner/unread - // path by injecting its own synthesized "⠋ Cursor Agent" and "Cursor ready" - // frames from the hook server (see src/main/index.ts). If we let cursor's - // bare title through, it lands in `runtimePaneTitlesByTabId` — where - // `getWorktreeStatus` reads from — and flips the sidebar dot back to solid - // within a second of the spinner appearing. Dropping the bare title before - // it reaches the store leaves the synthesized frame as the last-applied - // state until the next hook event overwrites it. Match is literal (trimmed, - // case-insensitive) so any task/chat title cursor auto-generates still - // passes through unchanged. - if (title.trim().toLowerCase() === 'cursor agent') { - return - } - lastEmittedTitle = normalizeTerminalTitle(title) - onTitleChange?.(lastEmittedTitle, title) - agentTracker?.handleTitle(title) - } - // Why: true while we're replaying buffered/attach-time bytes into the // terminal. Routes those bytes through onReplayData so the renderer can // engage the replay guard — otherwise xterm auto-replies to embedded @@ -144,78 +272,15 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }) ptyDataHandlers.set(id, (data) => { - // Why: OSC 9999 is a renderer-only control protocol. Parse it before - // xterm sees the bytes, and keep parser state across chunks so partial - // PTY reads do not drop valid status updates or print escape garbage. - const processed = processAgentStatusChunk(data) - data = processed.cleanData - // Why: mirror the onBell / onAgentBecameIdle guard below — during eager-buffer - // replay we must not surface stale agent-status payloads from a prior app - // session into the live store. The parser still consumes the bytes so they - // do not leak into xterm, we just suppress the callback. - if (onAgentStatus && !suppressAttentionEvents) { - for (const payload of processed.payloads) { - onAgentStatus(payload) - } - } - if (replayingBufferedData && storedCallbacks.onReplayData) { - storedCallbacks.onReplayData(data) - } else { - storedCallbacks.onData?.(data) - } - if (onTitleChange) { - // Why: feed EVERY OSC title in the chunk through the observer, not just - // the last one. node-pty + the main-process 8ms batch window commonly - // coalesce multiple title updates into a single IPC payload — for Pi's - // 80ms spinner + agent_end idle cycle, the last title in the chunk is - // the idle one and the intermediate working frames were silently - // dropped, so the worktree card never observed the working state. - // Processing titles in order preserves the working→idle transition - // that detectAgentStatusFromTitle and agentTracker both key off. - const titles = extractAllOscTitles(data) - if (titles.length > 0) { - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } - for (const title of titles) { - applyObservedTerminalTitle(title) - } - } else if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - } - staleTitleTimer = setTimeout(() => { - staleTitleTimer = null - if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { - const cleared = clearWorkingIndicators(lastEmittedTitle) - lastEmittedTitle = cleared - onTitleChange(cleared, cleared) - agentTracker?.handleTitle(cleared) - } - }, STALE_TITLE_TIMEOUT) - } - } - // Why: BEL is the attention signal. The detector is - // stateful across chunks so a BEL sitting inside an OSC sequence - // (e.g. Claude's `\e]0;title\a`) is correctly ignored — only true - // terminal bells raise attention. suppressAttentionEvents gates this - // during the synchronous eager-buffer replay so a historical BEL - // captured from the prior session does not produce a fresh alert on - // cold reattach. - if (onBell && bellDetector.chunkContainsBell(data) && !suppressAttentionEvents) { - onBell() - } + outputProcessor.processData(data, storedCallbacks, { + replayingBufferedData, + suppressAttentionEvents + }) }) } function clearAccumulatedState(): void { - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } - agentTracker?.reset() - bellDetector.reset() + outputProcessor.clearAccumulatedState() } function registerPtyExitHandler(id: string): void { @@ -383,17 +448,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // working→idle transition (and phantom cache-timer write) for a session // that was never live in this app instance. Cancel it so the replay has // no lingering side effects. - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } + outputProcessor.clearStaleTitleTimer() // Why: eager-buffered bytes may end mid-OSC (truncated/partial session // data), leaving bellDetector with inOsc = true. Without resetting, the // next real BEL in live data would be silently classified as an OSC // terminator and dropped. BEL is the sole attention signal per the PR // design, so this reset guards the attention pipeline against a silent // regression driven by replay state leaking into the live stream. - bellDetector.reset() + outputProcessor.resetBellDetector() } } bufferHandle.dispose() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts new file mode 100644 index 00000000000..cc86cd5df80 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts @@ -0,0 +1,81 @@ +export type RemoteRuntimePtyBatcher = { + push: (data: string) => void + flush: () => void + clear: () => void +} + +export type RemoteRuntimeViewportBatcher = { + queue: (cols: number, rows: number) => void + flush: () => void + clear: () => void +} + +export function createRemoteRuntimePtyTextBatcher( + delayMs: number, + onFlush: (text: string) => void +): RemoteRuntimePtyBatcher { + let pending = '' + let timer: ReturnType | null = null + + const clear = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + const flush = (): void => { + const text = pending + pending = '' + clear() + if (text) { + onFlush(text) + } + } + + return { + push(data: string): void { + pending += data + if (!timer) { + timer = setTimeout(flush, delayMs) + } + }, + flush, + clear + } +} + +export function createRemoteRuntimeViewportBatcher( + delayMs: number, + onFlush: (cols: number, rows: number) => void +): RemoteRuntimeViewportBatcher { + let pending: { cols: number; rows: number } | null = null + let timer: ReturnType | null = null + + const clear = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + const flush = (): void => { + const viewport = pending + pending = null + clear() + if (viewport) { + onFlush(viewport.cols, viewport.rows) + } + } + + return { + queue(cols: number, rows: number): void { + pending = { cols, rows } + if (!timer) { + timer = setTimeout(flush, delayMs) + } + }, + flush, + clear + } +} diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts new file mode 100644 index 00000000000..be400ee578f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts @@ -0,0 +1,47 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../shared/terminal-stream-protocol' + +export type RemoteRuntimeBinarySender = (bytes: Uint8Array) => void + +export function sendRemoteRuntimeTerminalInputFrame( + sendBinary: RemoteRuntimeBinarySender | null, + streamId: number | null, + text: string +): boolean { + if (!sendBinary || streamId === null) { + return false + } + sendBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 0, + payload: encodeTerminalStreamText(text) + }) + ) + return true +} + +export function sendRemoteRuntimeTerminalResizeFrame( + sendBinary: RemoteRuntimeBinarySender | null, + streamId: number | null, + cols: number, + rows: number +): boolean { + if (!sendBinary || streamId === null) { + return false + } + sendBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId, + seq: 0, + payload: encodeTerminalStreamJson({ cols, rows }) + }) + ) + return true +} diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts new file mode 100644 index 00000000000..c880d2c7c78 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -0,0 +1,512 @@ +/* eslint-disable max-lines -- Why: remote runtime PTY behavior spans JSON fallback, binary stream, lifecycle, and parser coverage; keeping the matrix together catches transport regressions. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../shared/terminal-stream-protocol' + +describe('createRemoteRuntimePtyTransport', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + + function emitMultiplexReady(): void { + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'ready' } + }) + } + + function latestSubscribePayload(): { + streamId: number + terminal: string + client: { id: string; type: string } + viewport?: { cols: number; rows: number } + } { + const frames = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) + const frame = frames.at(-1) + if (!frame) { + throw new Error('missing terminal subscribe frame') + } + const payload = decodeTerminalStreamJson<{ + streamId: number + terminal: string + client: { id: string; type: string } + viewport?: { cols: number; rows: number } + }>(frame.payload) + if (!payload) { + throw new Error('invalid terminal subscribe payload') + } + return payload + } + + function emitOutput(streamId: number, data: string): void { + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: 1, + payload: encodeTerminalStreamText(data) + }) + ) + } + + function emitSnapshot(streamId: number, data: string): void { + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson({ kind: 'scrollback' }) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(data) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array() + }) + ) + } + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + subscriptionCallbacks = null + subscriptionSendBinary.mockReset() + runtimeCall.mockResolvedValue({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(emitMultiplexReady) + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall, + subscribe: runtimeSubscribe + } + } + }) + }) + + it('attaches to an existing remote runtime terminal handle', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:terminal-1', + cols: 120, + rows: 40, + callbacks: { onError } + }) + + await vi.waitFor(() => { + expect(runtimeSubscribe).toHaveBeenCalled() + }) + + expect(onError).not.toHaveBeenCalled() + expect(transport.getPtyId()).toBe('remote:terminal-1') + expect(runtimeSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex', + params: {} + }), + expect.any(Object) + ) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + expect(latestSubscribePayload()).toMatchObject({ + terminal: 'terminal-1', + client: { id: 'desktop:tab-1:pane:1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 } + }) + }) + + it('routes encoded restored terminal ids to their owning runtime environment', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-2', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 120, + rows: 40, + callbacks: {} + }) + + await vi.waitFor(() => { + expect(runtimeSubscribe).toHaveBeenCalled() + }) + + expect(runtimeSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex' + }), + expect.any(Object) + ) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + expect(latestSubscribePayload()).toMatchObject({ + terminal: 'terminal-1', + viewport: { cols: 120, rows: 40 } + }) + }) + + it('closes a remote terminal created after the pane was destroyed', async () => { + let resolveCreate: (value: unknown) => void = () => {} + runtimeCall.mockImplementation((args) => { + if (args.method === 'terminal.create') { + return new Promise((resolve) => { + resolveCreate = resolve + }) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + const connect = transport.connect({ url: '', callbacks: {} }) + transport.destroy?.() + resolveCreate({ ok: true, result: { terminal: { handle: 'terminal-late' } } }) + await connect + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'terminal-late' }, + timeoutMs: 15_000 + }) + }) + + it('unsubscribes a remote terminal subscription that resolves after destroy', async () => { + let resolveSubscribe: (value: { + unsubscribe: () => void + sendBinary: typeof subscriptionSendBinary + }) => void = () => {} + const unsubscribe = vi.fn() + runtimeSubscribe.mockImplementation( + (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + return new Promise<{ unsubscribe: () => void; sendBinary: typeof subscriptionSendBinary }>( + (resolve) => { + resolveSubscribe = (value) => { + resolve(value) + queueMicrotask(emitMultiplexReady) + } + } + ) + } + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + const connect = transport.connect({ url: '', callbacks: {} }) + await vi.waitFor(() => { + expect(runtimeSubscribe).toHaveBeenCalled() + }) + transport.destroy?.() + resolveSubscribe({ unsubscribe, sendBinary: subscriptionSendBinary }) + await connect + + expect(unsubscribe).toHaveBeenCalled() + expect(transport.getPtyId()).toBeNull() + }) + + it('processes remote data chunks through title, bell, and OSC 9999 handlers before onData', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onData = vi.fn() + const onTitleChange = vi.fn() + const onBell = vi.fn() + const onAgentStatus = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + onTitleChange, + onBell, + onAgentStatus + }) + + await transport.connect({ url: '', callbacks: { onData } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + emitOutput( + streamId, + 'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after\x1b]0;. Claude working\x07\x07' + ) + + expect(onAgentStatus).toHaveBeenCalledWith({ + state: 'working', + prompt: 'ship it', + agentType: 'codex' + }) + expect(onData).toHaveBeenCalledWith('beforeafter\x1b]0;. Claude working\x07\x07') + expect(onTitleChange).toHaveBeenCalledWith('. Claude working', '. Claude working') + expect(onBell).toHaveBeenCalledTimes(1) + }) + + it('processes binary remote data chunks through the terminal parser', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onData = vi.fn() + const onTitleChange = vi.fn() + const onBell = vi.fn() + const onAgentStatus = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + onTitleChange, + onBell, + onAgentStatus + }) + + await transport.connect({ url: '', callbacks: { onData } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + emitOutput( + streamId, + 'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after' + ) + + expect(onAgentStatus).toHaveBeenCalledWith({ + state: 'working', + prompt: 'ship it', + agentType: 'codex' + }) + expect(onData).toHaveBeenCalledWith('beforeafter') + }) + + it('does not report PTY exit when the remote runtime subscription closes', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onExit = vi.fn() + const onDisconnect = vi.fn() + const onPtyExit = vi.fn() + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + onPtyExit + }) + + await transport.connect({ url: '', callbacks: { onExit, onDisconnect, onError } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + subscriptionCallbacks?.onClose?.() + + expect(onExit).not.toHaveBeenCalled() + expect(onDisconnect).not.toHaveBeenCalled() + expect(onPtyExit).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith('Remote Orca runtime closed the connection.') + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) + }) + + it('resubscribes with the latest pane viewport after the remote stream closes', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', cols: 80, rows: 24, callbacks: {} }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + expect(latestSubscribePayload().viewport).toEqual({ cols: 80, rows: 24 }) + + expect(transport.resize(132, 43)).toBe(true) + subscriptionCallbacks?.onClose?.() + + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => { + expect(latestSubscribePayload().viewport).toEqual({ cols: 132, rows: 43 }) + }) + }) + + it('coalesces rapid remote terminal input before sending it to the runtime', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + const { streamId } = latestSubscribePayload() + runtimeCall.mockClear() + subscriptionSendBinary.mockClear() + + expect(transport.sendInput('a')).toBe(true) + expect(transport.sendInput('b')).toBe(true) + expect(runtimeCall).not.toHaveBeenCalled() + + await vi.runOnlyPendingTimersAsync() + + expect(runtimeCall).not.toHaveBeenCalled() + expect(subscriptionSendBinary).toHaveBeenCalledTimes(1) + const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0]) + expect(frame?.opcode).toBe(TerminalStreamOpcode.Input) + expect(frame?.streamId).toBe(streamId) + expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('ab') + } finally { + vi.useRealTimers() + } + }) + + it('sends coalesced terminal input as binary frames once the stream is established', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + const { streamId } = latestSubscribePayload() + runtimeCall.mockClear() + subscriptionSendBinary.mockClear() + + expect(transport.sendInput('a')).toBe(true) + expect(transport.sendInput('b')).toBe(true) + await vi.runOnlyPendingTimersAsync() + + expect(runtimeCall).not.toHaveBeenCalled() + expect(subscriptionSendBinary).toHaveBeenCalledTimes(1) + const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0]) + expect(frame?.opcode).toBe(TerminalStreamOpcode.Input) + expect(frame?.streamId).toBe(streamId) + expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('ab') + } finally { + vi.useRealTimers() + } + }) + + it('coalesces rapid remote viewport updates before sending the latest size', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + const { streamId } = latestSubscribePayload() + runtimeCall.mockClear() + subscriptionSendBinary.mockClear() + + expect(transport.resize(80, 24)).toBe(true) + expect(transport.resize(120, 40)).toBe(true) + expect(runtimeCall).not.toHaveBeenCalled() + + await vi.runOnlyPendingTimersAsync() + + expect(runtimeCall).not.toHaveBeenCalled() + expect(subscriptionSendBinary).toHaveBeenCalledTimes(1) + const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0]) + expect(frame?.opcode).toBe(TerminalStreamOpcode.Resize) + expect(frame?.streamId).toBe(streamId) + expect(frame ? decodeTerminalStreamJson(frame.payload) : null).toEqual({ + cols: 120, + rows: 40 + }) + } finally { + vi.useRealTimers() + } + }) + + it('replays remote scrollback through the parser without firing stale attention events', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const onTitleChange = vi.fn() + const onBell = vi.fn() + const onAgentStatus = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + onTitleChange, + onBell, + onAgentStatus + }) + + await transport.connect({ url: '', callbacks: { onReplayData } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + emitSnapshot( + streamId, + 'before\x1b]9999;{"state":"working","prompt":"old","agentType":"codex"}\x07after\x1b]0;Remote title\x07\x07' + ) + + expect(onReplayData).toHaveBeenCalledWith('beforeafter\x1b]0;Remote title\x07\x07') + expect(onTitleChange).toHaveBeenCalledWith('Remote title', 'Remote title') + expect(onAgentStatus).not.toHaveBeenCalled() + expect(onBell).not.toHaveBeenCalled() + }) + + it('replays binary snapshot chunks without firing stale attention events', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const onTitleChange = vi.fn() + const onBell = vi.fn() + const onAgentStatus = vi.fn() + const onConnect = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + onTitleChange, + onBell, + onAgentStatus + }) + + await transport.connect({ url: '', callbacks: { onReplayData, onConnect } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + emitSnapshot( + streamId, + 'before\x1b]9999;{"state":"working","prompt":"old","agentType":"codex"}\x07after' + ) + + expect(onReplayData).toHaveBeenCalledWith('beforeafter') + expect(onAgentStatus).not.toHaveBeenCalled() + expect(onBell).not.toHaveBeenCalled() + expect(onConnect).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts new file mode 100644 index 00000000000..5f2d890a206 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -0,0 +1,324 @@ +/* eslint-disable max-lines -- Why: remote PTY transport keeps lifecycle, JSON fallback, and binary stream wiring together so reconnect/destroy ordering stays testable as one behavior surface. */ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeTerminalCreate } from '../../../../shared/runtime-types' +import type { PtyConnectResult, PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher' +import { createPtyOutputProcessor } from './pty-transport' +import { unwrapRuntimeRpcResult } from '../../runtime/runtime-rpc-client' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle, + runtimeTerminalErrorMessage, + toRemoteRuntimePtyId +} from '../../runtime/runtime-terminal-stream' +import { + getRemoteRuntimeTerminalMultiplexer, + type RemoteRuntimeMultiplexedTerminal +} from '../../runtime/remote-runtime-terminal-multiplexer' +import { + createRemoteRuntimePtyTextBatcher, + createRemoteRuntimeViewportBatcher +} from './remote-runtime-pty-batching' +import { setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides' +import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' + +const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8 +const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33 + +export function createRemoteRuntimePtyTransport( + runtimeEnvironmentId: string, + opts: IpcPtyTransportOptions = {} +): PtyTransport { + const { + command, + env, + worktreeId, + tabId, + leafId, + onPtyExit, + onPtySpawn, + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onAgentStatus + } = opts + let connected = false + let destroyed = false + let handle: string | null = null + let remotePtyId: string | null = null + let currentRuntimeEnvironmentId = runtimeEnvironmentId + let multiplexedStream: RemoteRuntimeMultiplexedTerminal | null = null + let desiredViewport: { cols: number; rows: number } | null = null + let storedCallbacks: Parameters[0]['callbacks'] = {} + let resubscribing = false + const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}` + const outputProcessor = createPtyOutputProcessor({ + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onAgentStatus + }) + + async function callRuntime(method: string, params?: unknown): Promise { + const response = await window.api.runtimeEnvironments.call({ + selector: currentRuntimeEnvironmentId, + method, + params, + timeoutMs: 15_000 + }) + return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + } + + async function closeRemoteTerminal(handleOverride?: string): Promise { + const targetHandle = handleOverride ?? handle + if (!targetHandle) { + return + } + try { + await callRuntime('terminal.close', { terminal: targetHandle }) + } catch { + // Best-effort parity with local disconnect/kill. + } + } + + const inputBatcher = createRemoteRuntimePtyTextBatcher(REMOTE_TERMINAL_INPUT_FLUSH_MS, (text) => { + const targetHandle = handle + if (!connected || !targetHandle) { + return + } + if (multiplexedStream?.sendInput(text)) { + return + } + void callRuntime('terminal.send', { + terminal: targetHandle, + text, + client: { id: clientId, type: 'desktop' } + }) + }) + + function sendViewportUpdate(cols: number, rows: number): void { + const targetHandle = handle + if (!connected || !targetHandle) { + return + } + if (multiplexedStream?.resize(cols, rows)) { + return + } + void callRuntime('terminal.updateViewport', { + terminal: targetHandle, + client: { id: clientId, type: 'desktop' }, + viewport: { cols, rows } + }).catch(() => {}) + } + + const viewportBatcher = createRemoteRuntimeViewportBatcher( + REMOTE_TERMINAL_VIEWPORT_FLUSH_MS, + sendViewportUpdate + ) + + function rememberViewport(cols: number, rows: number): void { + desiredViewport = { cols, rows } + } + + async function subscribeToHandle(): Promise { + if (!handle) { + return + } + const subscribedHandle = handle + const nextStream = await getRemoteRuntimeTerminalMultiplexer( + currentRuntimeEnvironmentId + ).subscribeTerminal({ + terminal: subscribedHandle, + client: { id: clientId, type: 'desktop' }, + viewport: desiredViewport ?? undefined, + callbacks: { + onData: (data) => outputProcessor.processData(data, storedCallbacks), + onSnapshot: (data) => { + if (data) { + outputProcessor.processData(data, storedCallbacks, { + replayingBufferedData: true, + suppressAttentionEvents: true + }) + } + }, + onSubscribed: () => { + storedCallbacks.onConnect?.() + storedCallbacks.onStatus?.('shell') + }, + onEnd: () => { + outputProcessor.clearAccumulatedState() + connected = false + storedCallbacks.onExit?.(0) + storedCallbacks.onDisconnect?.() + if (remotePtyId) { + onPtyExit?.(remotePtyId) + } + }, + onError: (message) => storedCallbacks.onError?.(message), + onFitOverrideChanged: (event) => { + if (remotePtyId) { + setFitOverride(remotePtyId, event.mode, event.cols, event.rows) + } + }, + onDriverChanged: (driver) => { + if (remotePtyId) { + setDriverForPty(remotePtyId, driver) + } + }, + onTransportClose: () => { + multiplexedStream = null + if (destroyed || !connected || !handle || resubscribing) { + return + } + resubscribing = true + void subscribeToHandle() + .catch((error) => storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))) + .finally(() => { + resubscribing = false + }) + } + } + }) + if (destroyed || !connected || handle !== subscribedHandle) { + nextStream.close() + return + } + multiplexedStream = nextStream + } + + return { + async connect(options) { + storedCallbacks = options.callbacks + if (destroyed || !worktreeId) { + return + } + + try { + const created = await callRuntime<{ terminal: RuntimeTerminalCreate }>('terminal.create', { + worktree: worktreeId, + command, + env, + focus: false + }) + handle = created.terminal.handle + if (destroyed) { + await closeRemoteTerminal(created.terminal.handle) + return + } + + remotePtyId = toRemoteRuntimePtyId(handle, currentRuntimeEnvironmentId) + connected = true + desiredViewport = { + cols: options.cols ?? 80, + rows: options.rows ?? 24 + } + onPtySpawn?.(remotePtyId) + + await subscribeToHandle() + if (destroyed || !connected || !remotePtyId) { + return + } + + return { + id: remotePtyId, + replay: '' + } satisfies PtyConnectResult + } catch (error) { + storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)) + return undefined + } + }, + + attach(options) { + storedCallbacks = options.callbacks + currentRuntimeEnvironmentId = + getRemoteRuntimePtyEnvironmentId(options.existingPtyId) ?? runtimeEnvironmentId + handle = getRemoteRuntimeTerminalHandle(options.existingPtyId) + if (!handle) { + connected = false + remotePtyId = null + storedCallbacks.onError?.('Remote runtime terminal id is invalid.') + return + } + remotePtyId = options.existingPtyId + connected = true + desiredViewport = { + cols: options.cols ?? 80, + rows: options.rows ?? 24 + } + void subscribeToHandle().catch((error) => { + connected = false + storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)) + }) + }, + + disconnect() { + inputBatcher.flush() + viewportBatcher.flush() + outputProcessor.clearAccumulatedState() + if (!connected && !handle) { + return + } + connected = false + const id = remotePtyId + multiplexedStream?.close() + multiplexedStream = null + void closeRemoteTerminal() + handle = null + remotePtyId = null + storedCallbacks.onDisconnect?.() + if (id) { + onPtyExit?.(id) + } + }, + + detach() { + inputBatcher.flush() + viewportBatcher.flush() + outputProcessor.clearAccumulatedState() + connected = false + multiplexedStream?.close() + multiplexedStream = null + storedCallbacks = {} + }, + + sendInput(data: string): boolean { + if (!connected || !handle) { + return false + } + // Why: remote terminal input currently crosses the runtime RPC boundary; + // coalescing same-frame key bursts avoids a per-keystroke remote round-trip. + inputBatcher.push(data) + return true + }, + + resize(cols: number, rows: number): boolean { + if (!connected || !handle) { + return false + } + rememberViewport(cols, rows) + // Why: xterm fit can emit resize bursts while the user drags panes or + // restores layouts. Remote runtimes only need the last viewport in a frame. + viewportBatcher.queue(cols, rows) + return true + }, + + isConnected() { + return connected + }, + + getPtyId() { + return remotePtyId + }, + + destroy() { + destroyed = true + this.disconnect() + inputBatcher.clear() + viewportBatcher.clear() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts b/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts index fe8f89c5f7c..3420f1806d1 100644 --- a/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts +++ b/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts @@ -3,7 +3,12 @@ import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' function installGetCwd(fn: (id: string) => Promise): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shim for window.api.pty.getCwd - ;(globalThis as any).window = { api: { pty: { getCwd: fn } } } + ;(globalThis as any).window = { + api: { + pty: { getCwd: fn }, + runtimeEnvironments: { call: vi.fn() } + } + } } describe('resolveSplitCwd', () => { @@ -104,4 +109,17 @@ describe('resolveSplitCwd', () => { expect(result).toBe('/worktree') expect(getCwd).not.toHaveBeenCalled() }) + + it('skips local PTY IPC for remote runtime PTY ids', async () => { + const getCwd = vi.fn() + installGetCwd(getCwd as unknown as (id: string) => Promise) + const result = await resolveSplitCwd({ + paneCwdMap: new Map(), + sourcePaneId: 1, + sourcePtyId: 'remote:term-1', + fallbackCwd: '/remote/worktree' + }) + expect(result).toBe('/remote/worktree') + expect(getCwd).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts b/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts index ce06202350c..300543d4718 100644 --- a/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts +++ b/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts @@ -3,6 +3,7 @@ // `/proc`-or-lsof-backed IPC fallback for shells that never emit OSC 7 (agent // TUIs, minimal sh). Both layers can legitimately come back empty, so the // helper always finishes by returning the caller's worktree-root fallback. +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' export type PaneCwdEntry = { cwd: string; confirmed: boolean } @@ -32,7 +33,7 @@ export async function resolveSplitCwd(args: { // 2) Ask the PTY provider (/proc or lsof). Enforce a soft timeout // renderer-side so a slow SSH relay can't stall the split. - if (sourcePtyId) { + if (sourcePtyId && !isRemoteRuntimePtyId(sourcePtyId)) { try { const ipcCwd = await Promise.race([ window.api.pty.getCwd(sourcePtyId).catch(() => null), diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts new file mode 100644 index 00000000000..2a4a29743c5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + toastLoading: vi.fn(() => 'toast-1'), + toastDismiss: vi.fn(), + toastError: vi.fn(), + importExternalPathsToRuntime: vi.fn(), + storeState: { + settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, + worktreesByRepo: { + repo1: [{ id: 'wt-1', path: '/remote/repo' }] + } + } +})) + +vi.mock('sonner', () => ({ + toast: { + loading: mocks.toastLoading, + dismiss: mocks.toastDismiss, + error: mocks.toastError, + message: vi.fn() + } +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mocks.storeState + } +})) + +vi.mock('@/runtime/runtime-file-client', () => ({ + importExternalPathsToRuntime: mocks.importExternalPathsToRuntime +})) + +import { handleTerminalFileDrop, resolveTerminalDropTargetShell } from './terminal-drop-handler' + +describe('handleTerminalFileDrop', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + mocks.storeState.worktreesByRepo = { + repo1: [{ id: 'wt-1', path: '/remote/repo' }] + } + }) + + it('uploads client-local drops into the active runtime before pasting paths', async () => { + mocks.importExternalPathsToRuntime.mockResolvedValue({ + results: [ + { + sourcePath: '/Users/me/logo.png', + status: 'imported', + destPath: '/remote/repo/.orca/drops/logo.png', + kind: 'file', + renamed: false + } + ] + }) + const sendInput = vi.fn() + const focus = vi.fn() + const manager = { + getActivePane: () => ({ id: 1, terminal: { focus } }), + getPanes: () => [] + } + const paneTransports = new Map([[1, { sendInput }]]) + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: paneTransports as never, + worktreeId: 'wt-1', + cwd: undefined, + data: { paths: ['/Users/me/logo.png'], target: 'terminal' } + }) + + expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/logo.png'], + '/remote/repo/.orca/drops' + ) + expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/logo.png ') + expect(focus).toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') + }) + + it('uses Windows shell paths for forward-slash UNC runtime worktrees', async () => { + mocks.storeState.worktreesByRepo = { + repo1: [{ id: 'wt-1', path: '//server/share/repo' }] + } + mocks.importExternalPathsToRuntime.mockResolvedValue({ + results: [ + { + sourcePath: '/Users/me/logo.png', + status: 'imported', + destPath: '//server/share/repo\\.orca\\drops\\logo.png', + kind: 'file', + renamed: false + } + ] + }) + const sendInput = vi.fn() + const focus = vi.fn() + const manager = { + getActivePane: () => ({ id: 1, terminal: { focus } }), + getPanes: () => [] + } + const paneTransports = new Map([[1, { sendInput }]]) + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: paneTransports as never, + worktreeId: 'wt-1', + cwd: undefined, + data: { paths: ['/Users/me/logo.png'], target: 'terminal' } + }) + + expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '//server/share/repo' + }, + ['/Users/me/logo.png'], + '\\\\server\\share\\repo\\.orca\\drops' + ) + expect(sendInput).toHaveBeenCalledWith('\\\\server\\share\\repo\\.orca\\drops\\logo.png ') + }) +}) + +describe('resolveTerminalDropTargetShell', () => { + it('uses runtime worktree path shape for active Windows runtimes', () => { + expect( + resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId: 'env-1', + worktreePath: '//Server/Share/Repo', + connectionId: null, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' + }) + ).toBe('windows') + }) + + it('uses runtime worktree path shape for active POSIX runtimes', () => { + expect( + resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId: 'env-1', + worktreePath: '/home/orca/repo', + connectionId: null, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + }) + ).toBe('posix') + }) + + it('keeps legacy SSH drops POSIX when no runtime environment is active', () => { + expect( + resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId: null, + worktreePath: 'C:\\repo', + connectionId: 'ssh-1', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + }) + ).toBe('posix') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts index 52220bf6af4..df4677e2719 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts @@ -5,6 +5,8 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { useAppStore } from '@/store' import { isWindowsUserAgent, shellEscapePath } from './pane-helpers' import type { PtyTransport } from './pty-transport' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' type Args = { manager: PaneManager @@ -14,6 +16,32 @@ type Args = { data: { paths: string[]; target: string; tabId?: string } } +export type TerminalTargetShell = 'posix' | 'windows' + +export function getTerminalTargetShellForWorktreePath(worktreePath: string): TerminalTargetShell { + return isWindowsPathLike(worktreePath) ? 'windows' : 'posix' +} + +export function resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId, + worktreePath, + connectionId, + userAgent +}: { + activeRuntimeEnvironmentId: string | null | undefined + worktreePath: string | null | undefined + connectionId: string | null | undefined + userAgent?: string +}): TerminalTargetShell { + if (activeRuntimeEnvironmentId?.trim() && worktreePath) { + return getTerminalTargetShellForWorktreePath(worktreePath) + } + if (typeof connectionId === 'string') { + return 'posix' + } + return isWindowsUserAgent(userAgent) ? 'windows' : 'posix' +} + /** * Handle a native file drop targeted at a terminal pane. * @@ -36,6 +64,51 @@ export async function handleTerminalFileDrop(args: Args): Promise { if (!transport) { return } + const settings = useAppStore.getState().settings + const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() + const worktreePath = resolveWorktreePath(worktreeId, cwd) + if (!worktreePath) { + toast.error('Worktree path not available.') + return + } + + if (activeRuntimeEnvironmentId) { + const targetShell = getTerminalTargetShellForWorktreePath(worktreePath) + const destinationDir = joinRuntimeDropDir(worktreePath) + const pending = toast.loading( + `Uploading ${data.paths.length} file${data.paths.length === 1 ? '' : 's'} to runtime…` + ) + try { + const { results } = await importExternalPathsToRuntime( + { + settings, + worktreeId, + worktreePath + }, + data.paths, + destinationDir + ) + const imported = results.filter((result) => result.status === 'imported') + const skipped = results.filter((result) => result.status === 'skipped') + const failed = results.filter((result) => result.status === 'failed') + const liveTransport = paneTransports.get(paneId) + if (liveTransport) { + for (const result of imported) { + const shellPath = isWindowsPathLike(worktreePath) + ? result.destPath.replace(/\//g, '\\') + : result.destPath + liveTransport.sendInput(`${shellEscapePath(shellPath, targetShell)} `) + } + pane.terminal.focus() + } + reportUploadSkipsAndFailures(skipped, failed) + } catch (err) { + toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) + } finally { + toast.dismiss(pending) + } + return + } // Why: `getConnectionId` returns `string` (SSH), `null` (local repo found), // or `undefined` (store not hydrated / worktree not found). Treat @@ -47,11 +120,11 @@ export async function handleTerminalFileDrop(args: Args): Promise { return } const isRemote = connectionId !== null - const targetShell: 'posix' | 'windows' = isRemote - ? 'posix' - : isWindowsUserAgent() - ? 'windows' - : 'posix' + const targetShell = resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId: null, + worktreePath, + connectionId + }) // Why: local fast path — no IPC round-trip, no toast — preserves today's // zero-latency drop behavior. Trailing space separates multiple paths in @@ -64,12 +137,6 @@ export async function handleTerminalFileDrop(args: Args): Promise { return } - const worktreePath = resolveWorktreePath(worktreeId, cwd) - if (!worktreePath) { - toast.error('Worktree path not available.') - return - } - const pending = toast.loading( `Uploading ${data.paths.length} file${data.paths.length === 1 ? '' : 's'} to remote…` ) @@ -90,22 +157,7 @@ export async function handleTerminalFileDrop(args: Args): Promise { } pane.terminal.focus() } - if (skipped.length > 0) { - // Why: symlink rejection is policy, not error — show as neutral - // message. Mixed skips collapse to a single "items" count to avoid - // enumerating every reason. - const symlinkCount = skipped.filter((s) => s.reason === 'symlink').length - const noun = skipped.length === 1 ? 'item' : 'items' - toast.message( - symlinkCount === skipped.length - ? `Skipped ${skipped.length} symlink${skipped.length === 1 ? '' : 's'}.` - : `Skipped ${skipped.length} ${noun}.` - ) - } - if (failed.length > 0) { - const noun = failed.length === 1 ? 'file' : 'files' - toast.error(`Failed to upload ${failed.length} ${noun}.`) - } + reportUploadSkipsAndFailures(skipped, failed) } catch (err) { toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) } finally { @@ -113,9 +165,42 @@ export async function handleTerminalFileDrop(args: Args): Promise { } } +function reportUploadSkipsAndFailures( + skipped: { reason: string }[], + failed: { reason: string }[] +): void { + if (skipped.length > 0) { + // Why: symlink rejection is policy, not error — show as neutral + // message. Mixed skips collapse to a single "items" count to avoid + // enumerating every reason. + const symlinkCount = skipped.filter((s) => s.reason === 'symlink').length + const noun = skipped.length === 1 ? 'item' : 'items' + toast.message( + symlinkCount === skipped.length + ? `Skipped ${skipped.length} symlink${skipped.length === 1 ? '' : 's'}.` + : `Skipped ${skipped.length} ${noun}.` + ) + } + if (failed.length > 0) { + const noun = failed.length === 1 ? 'file' : 'files' + toast.error(`Failed to upload ${failed.length} ${noun}.`) + } +} + function resolveWorktreePath(worktreeId: string, fallbackCwd: string | undefined): string | null { const state = useAppStore.getState() const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat() const worktree = allWorktrees.find((w) => w.id === worktreeId) return worktree?.path ?? fallbackCwd ?? null } + +function joinRuntimeDropDir(worktreePath: string): string { + if (isWindowsPathLike(worktreePath)) { + return `${worktreePath.replace(/[\\/]+$/, '').replace(/\//g, '\\')}\\.orca\\drops` + } + return `${worktreePath.replace(/[\\/]+$/, '')}/.orca/drops` +} + +function isWindowsPathLike(path: string): boolean { + return isWindowsAbsolutePathLike(path) || path.includes('\\') +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index d767824bf70..5aa8b5766e2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: terminal link routing has intertwined local, +SSH, and runtime cases; keeping them in one suite prevents fixture drift. */ import type { IDisposable, ILink } from '@xterm/xterm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PaneManager } from '@/lib/pane-manager/pane-manager' @@ -9,18 +11,29 @@ import { openDetectedFilePath } from './terminal-link-handlers' import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { getConnectionId } from '@/lib/connection-context' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const openUrlMock = vi.fn() const openFileUriMock = vi.fn() +const openFilePathMock = vi.fn() const openFileMock = vi.fn() const authorizeExternalPathMock = vi.fn() const statMock = vi.fn().mockResolvedValue({ isDirectory: false }) +const runtimeEnvironmentCallMock = vi.fn() +const runtimeEnvironmentTransportCallMock = vi.fn() const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() const deps = { worktreeId: 'wt-1', worktreePath: '/tmp' } const storeState = { - settings: undefined as { openLinksInApp?: boolean } | undefined, + settings: undefined as + | { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null } + | undefined, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock, openFile: openFileMock @@ -44,12 +57,22 @@ vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(() => null) +})) + function setPlatform(userAgent: string): void { vi.stubGlobal('navigator', { userAgent }) } beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() + runtimeEnvironmentTransportCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCallMock(args) + }) + vi.mocked(getConnectionId).mockReturnValue(null) storeState.settings = undefined registerHttpLinkStoreAccessor(() => storeState) vi.stubGlobal('window', { @@ -57,12 +80,14 @@ beforeEach(() => { shell: { openUrl: openUrlMock, openFileUri: openFileUriMock, + openFilePath: openFilePathMock, pathExists: vi.fn().mockResolvedValue(true) }, fs: { authorizeExternalPath: authorizeExternalPathMock, stat: statMock - } + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCallMock } } }) }) @@ -240,6 +265,127 @@ describe('handleOscLink', () => { }) ) }) + + it('stats remote-runtime file links through the active runtime environment', async () => { + setPlatform('Macintosh') + storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + runtimeEnvironmentCallMock.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { size: 1, isDirectory: false, mtime: 1 }, + _meta: { runtimeId: 'remote-runtime' } + }) + + openDetectedFilePath('/tmp/src/main.ts', null, null, deps) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'src/main.ts' }, + timeoutMs: 15_000 + }) + }) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/tmp/src/main.ts', + relativePath: 'src/main.ts' + }) + ) + }) + + it('stats remote-runtime file links through the owning PTY runtime environment', async () => { + setPlatform('Macintosh') + storeState.settings = { activeRuntimeEnvironmentId: 'env-2' } + runtimeEnvironmentCallMock.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { size: 1, isDirectory: false, mtime: 1 }, + _meta: { runtimeId: 'remote-runtime' } + }) + + openDetectedFilePath('/tmp/src/main.ts', null, null, { + ...deps, + runtimeEnvironmentId: 'env-1' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'src/main.ts' }, + timeoutMs: 15_000 + }) + }) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/tmp/src/main.ts', + relativePath: 'src/main.ts', + runtimeEnvironmentId: 'env-1' + }) + ) + }) + + it('opens SSH file links through Orca without local authorization', async () => { + setPlatform('Macintosh') + vi.mocked(getConnectionId).mockReturnValue('ssh-1') + + openDetectedFilePath('/home/me/repo/src/main.ts', null, null, { + worktreeId: 'wt-1', + worktreePath: '/home/me/repo' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(statMock).toHaveBeenCalledWith({ + filePath: '/home/me/repo/src/main.ts', + connectionId: 'ssh-1' + }) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/home/me/repo/src/main.ts', + relativePath: 'src/main.ts' + }) + ) + }) + + it('does not open SSH html file links as client-local file browser tabs', async () => { + setPlatform('Macintosh') + vi.mocked(getConnectionId).mockReturnValue('ssh-1') + + openDetectedFilePath('/home/me/repo/report.html', null, null, { + worktreeId: 'wt-1', + worktreePath: '/home/me/repo' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/home/me/repo/report.html', + relativePath: 'report.html' + }) + ) + }) + + it('does not ask the client OS to open SSH directories', async () => { + setPlatform('Macintosh') + vi.mocked(getConnectionId).mockReturnValue('ssh-1') + statMock.mockResolvedValueOnce({ isDirectory: true }) + + openDetectedFilePath('/home/me/repo/src', null, null, { + worktreeId: 'wt-1', + worktreePath: '/home/me/repo' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(openFilePathMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + }) }) describe('createFilePathLinkProvider range bounds', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 13af7f80be9..582fac1e131 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -13,6 +13,13 @@ import { absolutePathToFileUri } from '@/components/editor/markdown-internal-lin import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { openHttpLink } from '@/lib/http-link-routing' +import { + isRemoteRuntimeFileOperation, + runtimePathExists, + statRuntimePath, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' export type LinkHandlerDeps = { worktreeId: string @@ -21,6 +28,8 @@ export type LinkHandlerDeps = { managerRef: React.RefObject linkProviderDisposablesRef: React.RefObject> pathExistsCache: Map + runtimeEnvironmentId?: string | null + getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null } type TerminalLinkEvent = Pick & @@ -64,28 +73,47 @@ function openHtmlFileInBrowser(filePath: string, worktreeId: string): void { store.createBrowserTab(worktreeId, fileUrl, { title, activate: true }) } +function getTerminalFileContext( + worktreeId: string, + worktreePath: string, + runtimeEnvironmentId?: string | null +): RuntimeFileOperationArgs { + const settings = useAppStore.getState().settings + return { + settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), + worktreeId: worktreeId || null, + worktreePath, + connectionId: getConnectionId(worktreeId || null) ?? undefined + } +} + export function openDetectedFilePath( filePath: string, line: number | null, column: number | null, - deps: Pick + deps: Pick ): void { - const { worktreeId, worktreePath } = deps + const { runtimeEnvironmentId, worktreeId, worktreePath } = deps void (async () => { let statResult try { - const connectionId = getConnectionId(deps.worktreeId ?? null) ?? undefined - // Why: remote paths don't need local auth — the relay is the security boundary. - if (!connectionId) { + const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + const isRemoteRuntimePath = isRemoteRuntimeFileOperation(fileContext, filePath) + // Why: remote paths don't need local auth — the relay/runtime is the security boundary. + if (!fileContext.connectionId && !isRemoteRuntimePath) { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) } - statResult = await window.api.fs.stat({ filePath, connectionId }) + statResult = await statRuntimePath(fileContext, filePath) } catch { return } if (statResult.isDirectory) { + const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + if (fileContext.connectionId || isRemoteRuntimeFileOperation(fileContext, filePath)) { + return + } await window.api.shell.openFilePath(filePath) return } @@ -94,7 +122,12 @@ export function openDetectedFilePath( // as source in Monaco — ⌘/Ctrl+click on an HTML path in the terminal should // feel like clicking an http link and render the page, not dump HTML source. // Mirrors the editor's "Open Preview to the Side" action. - if (isHtmlFilePath(filePath)) { + const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + if ( + isHtmlFilePath(filePath) && + !fileContext.connectionId && + !isRemoteRuntimeFileOperation(fileContext, filePath) + ) { openHtmlFileInBrowser(filePath, worktreeId) return } @@ -120,7 +153,8 @@ export function openDetectedFilePath( relativePath, worktreeId: worktreeId || '', language: detectLanguage(filePath), - mode: 'edit' + mode: 'edit', + runtimeEnvironmentId: runtimeEnvironmentId ?? undefined }) if (line !== null) { @@ -172,9 +206,18 @@ export function createFilePathLinkProvider( return null } - const cachedExists = pathExistsCache.get(resolved.absolutePath) - const exists = cachedExists ?? (await window.api.shell.pathExists(resolved.absolutePath)) - pathExistsCache.set(resolved.absolutePath, exists) + const runtimeEnvironmentId = + deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null + const cacheKey = `${runtimeEnvironmentId ?? 'active'}\0${resolved.absolutePath}` + const cachedExists = pathExistsCache.get(cacheKey) + const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + const exists = + cachedExists ?? + (fileContext.connectionId || + isRemoteRuntimeFileOperation(fileContext, resolved.absolutePath) + ? await runtimePathExists(fileContext, resolved.absolutePath) + : await window.api.shell.pathExists(resolved.absolutePath)) + pathExistsCache.set(cacheKey, exists) if (!exists) { return null } @@ -197,7 +240,8 @@ export function createFilePathLinkProvider( } openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, { worktreeId, - worktreePath + worktreePath, + runtimeEnvironmentId }) }, hover: () => { @@ -235,7 +279,7 @@ export function handleOscLink( rawText: string, event: TerminalLinkEvent | undefined, deps: Pick & - Partial> + Partial> ): void { if (!isTerminalLinkActivation(event)) { return diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 5823891ad7f..af06537c444 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -38,6 +38,7 @@ import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-optio import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { connectPanePty } from './pty-connection' import type { PtyTransport } from './pty-transport' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' @@ -296,7 +297,11 @@ export function useTerminalPaneLifecycle({ startupCwd, managerRef, linkProviderDisposablesRef, - pathExistsCache + pathExistsCache, + getRuntimeEnvironmentIdForPane: (paneId) => { + const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() + return ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null + } } let resizeRaf: number | null = null const queueResizeAll = (focusActive: boolean): void => { @@ -488,7 +493,10 @@ export function useTerminalPaneLifecycle({ pane.terminal.options.linkHandler = { allowNonHttpProtocols: true, activate: (event, text) => { - handleOscLink(text, event as MouseEvent | undefined, linkDeps) + handleOscLink(text, event as MouseEvent | undefined, { + ...linkDeps, + runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null + }) // Why: Cmd/Ctrl+clicking a link activates Orca handling (open file, // new browser tab, system browser) which can steal focus from the // terminal before the click's mouseup reaches ownerDocument. Without @@ -685,7 +693,13 @@ export function useTerminalPaneLifecycle({ if (!event) { return } - void handleOscLink(url, event, linkDeps) + const activePane = managerRef.current?.getActivePane() + void handleOscLink(url, event, { + ...linkDeps, + runtimeEnvironmentId: activePane + ? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null) + : null + }) // Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus // away from the terminal before the click's mouseup reaches // ownerDocument. That leaves xterm's SelectionService drag-select diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 88c2e46b317..3acdc7f96b4 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -17,6 +17,7 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agen import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { isGitRepoKind } from '../../../shared/repo-kind' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GitHubWorkItem, GitPushTarget, @@ -49,6 +50,9 @@ import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-sug import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths' +import { joinPath } from '@/lib/path' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' export type UseComposerStateOptions = { initialRepoId?: string @@ -392,6 +396,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // multi-instance routing). const agentPromptRef = useRef(agentPrompt) agentPromptRef.current = agentPrompt + const connectionIdRef = useRef(connectionId) + connectionIdRef.current = connectionId const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) @@ -404,18 +410,30 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS null ) const selectedRepoPath = selectedRepo?.path + const selectedRepoPathRef = useRef(selectedRepoPath) + selectedRepoPathRef.current = selectedRepoPath + const settingsRef = useRef(settings) + settingsRef.current = settings useEffect(() => { - if (!selectedRepoPath) { + if (!selectedRepo || !selectedRepoPath) { setSelectedRepoSlug(null) return } let cancelled = false - void ( - window.api.gh.repoSlug({ repoPath: selectedRepoPath }) as Promise<{ - owner: string - repo: string - } | null> - ) + const target = getActiveRuntimeTarget(settings) + const request = + target.kind === 'environment' + ? callRuntimeRpc<{ owner: string; repo: string } | null>( + target, + 'github.repoSlug', + { repo: selectedRepo.id }, + { timeoutMs: 30_000 } + ) + : (window.api.gh.repoSlug({ repoPath: selectedRepoPath }) as Promise<{ + owner: string + repo: string + } | null>) + void request .then((result) => { if (cancelled) { return @@ -430,7 +448,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [selectedRepoPath]) + }, [selectedRepo, selectedRepoPath, settings]) const sparsePresetsForRepo = sparsePresetsByRepo[repoId] const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS const normalizedSparseDirectories = useMemo( @@ -702,8 +720,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setYamlHooks(null) setCheckedHooksRepoId(null) - void window.api.hooks - .check({ repoId }) + void checkRuntimeHooks(settings, repoId) .then((result) => { if (!cancelled) { setYamlHooks(result.hooks) @@ -717,8 +734,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } }) - void window.api.hooks - .readIssueCommand({ repoId }) + void readRuntimeIssueCommand(settings, repoId) .then((result) => { if (!cancelled) { setIssueCommandTemplate(result.effectiveContent ?? '') @@ -735,7 +751,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [repoId]) + }, [repoId, settings]) // Why: warm the Start-from picker's PR cache on composer mount and whenever // the selected repo changes so opening the picker paints instantly from @@ -779,8 +795,17 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setLinkItemsLoading(true) const lookupRepoId = selectedRepo.id - void window.api.gh - .listWorkItems({ repoPath: selectedRepo.path, limit: 100 }) + const target = getActiveRuntimeTarget(settings) + const request = + target.kind === 'environment' + ? callRuntimeRpc>>( + target, + 'github.listWorkItems', + { repo: selectedRepo.id, limit: 100 }, + { timeoutMs: 30_000 } + ) + : window.api.gh.listWorkItems({ repoPath: selectedRepo.path, limit: 100 }) + void request .then((envelope) => { if (!cancelled) { // Why: IPC payload omits repoId — stamp it here from the repo we @@ -827,7 +852,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [linkPopoverOpen, selectedRepo]) + }, [linkPopoverOpen, selectedRepo, settings]) useEffect(() => { if (!linkPopoverOpen || !selectedRepo || normalizedLinkQuery.directNumber === null) { @@ -843,8 +868,20 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // resolving direct lookups against the selected repo instead of requiring a // text match in the recent-items list. const lookupRepoId = selectedRepo.id - void window.api.gh - .workItem({ repoPath: selectedRepo.path, number: normalizedLinkQuery.directNumber }) + const target = getActiveRuntimeTarget(settings) + const request = + target.kind === 'environment' + ? callRuntimeRpc>>( + target, + 'github.workItem', + { repo: selectedRepo.id, number: normalizedLinkQuery.directNumber }, + { timeoutMs: 30_000 } + ) + : window.api.gh.workItem({ + repoPath: selectedRepo.path, + number: normalizedLinkQuery.directNumber + }) + void request .then((item) => { if (!cancelled) { setLinkDirectItem( @@ -866,7 +903,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo]) + }, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo, settings]) const applyLinkedWorkItem = useCallback( (item: GitHubWorkItem): void => { @@ -975,23 +1012,165 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [name] ) + + const addComposerAttachments = useCallback((paths: string[]): void => { + if (paths.length === 0) { + return + } + setAttachmentPaths((current) => { + const next = [...current] + for (const pathValue of paths) { + if (!next.includes(pathValue)) { + next.push(pathValue) + } + } + return next + }) + }, []) + + const insertComposerFolderPaths = useCallback((folderPaths: string[]): void => { + if (folderPaths.length === 0) { + return + } + // Why: de-dup within a single drop — the OS occasionally delivers the + // same folder twice when a user drags from a selection that includes both + // the item and its parent, and we don't want to insert it multiple times. + const uniqueFolderPaths = Array.from(new Set(folderPaths)) + // Why: wrap paths containing shell metacharacters in double quotes (and + // escape embedded quotes) so inserted folder refs stay a single token if + // pasted into a terminal. Simple paths stay unadorned to match OS drops. + const formatPath = (p: string): string => { + if (/[\s"'$`\\()[\]{}*?!;&|<>#~]/.test(p)) { + return `"${p.replace(/(["\\$`])/g, '\\$1')}"` + } + return p + } + const insertion = uniqueFolderPaths.map(formatPath).join(' ') + const textarea = promptTextareaRef.current + // Why: compute selection, insertion, and caret target OUTSIDE the + // setAgentPrompt updater so the updater stays pure. React Strict Mode + // double-invokes updaters in dev, and batching can delay execution. + const current = agentPromptRef.current + const selStart = textarea?.selectionStart ?? current.length + const selEnd = textarea?.selectionEnd ?? current.length + const before = current.slice(0, selStart) + const after = current.slice(selEnd) + // Why: pad with single spaces when the caret sits directly against other + // text so the folder path doesn't merge into an adjacent word. + const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) + const needsTrailingSpace = after.length > 0 && !/^\s/.test(after) + const padded = `${needsLeadingSpace ? ' ' : ''}${insertion}${needsTrailingSpace ? ' ' : ''}` + const caret = before.length + padded.length + if (textarea) { + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(caret, caret) + }) + } + // Why: pass a plain value (not an updater) since `before`/`after` were + // already resolved from `agentPromptRef.current`; this keeps the state + // write side-effect-free under Strict-Mode double-render. + setAgentPrompt(before + padded + after) + }, []) + + const uploadComposerPaths = useCallback( + async ( + sourcePaths: string[], + targetSettings = settings, + targetConnectionId = connectionId, + targetRepoPath = selectedRepoPath + ): Promise<{ filePaths: string[]; folderPaths: string[] } | null> => { + if (!targetSettings?.activeRuntimeEnvironmentId?.trim() && !targetConnectionId) { + return null + } + if (!targetRepoPath) { + toast.error('No remote repository path is available for attachments.') + return { filePaths: [], folderPaths: [] } + } + const destinationDir = joinPath(targetRepoPath, '.orca/drops') + const { results } = await importExternalPathsToRuntime( + { + settings: targetSettings, + worktreeId: targetRepoPath, + worktreePath: targetRepoPath, + connectionId: targetConnectionId ?? undefined + }, + sourcePaths, + destinationDir, + { ensureDestinationDir: true } + ) + const filePaths: string[] = [] + const folderPaths: string[] = [] + let skippedOrFailed = 0 + for (const result of results) { + if (result.status !== 'imported') { + skippedOrFailed += 1 + continue + } + if (result.kind === 'directory') { + folderPaths.push(result.destPath) + } else { + filePaths.push(result.destPath) + } + } + if (skippedOrFailed > 0) { + toast.error('Some attachments could not be uploaded.') + } + return { filePaths, folderPaths } + }, + [connectionId, selectedRepoPath, settings] + ) + const handleAddAttachment = useCallback(async (): Promise => { try { const selectedPath = await window.api.shell.pickAttachment() if (!selectedPath) { return } - setAttachmentPaths((current) => { - if (current.includes(selectedPath)) { - return current - } - return [...current, selectedPath] - }) + const uploaded = await uploadComposerPaths([selectedPath]) + if (uploaded) { + addComposerAttachments(uploaded.filePaths) + insertComposerFolderPaths(uploaded.folderPaths) + return + } + addComposerAttachments([selectedPath]) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to add attachment.' toast.error(message) } - }, []) + }, [addComposerAttachments, insertComposerFolderPaths, uploadComposerPaths]) + + const applyLocalComposerDrop = useCallback( + async (paths: string[]): Promise => { + const fileAttachments: string[] = [] + const folderPaths: string[] = [] + for (const filePath of paths) { + try { + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + const stat = await window.api.fs.stat({ filePath }) + if (stat.isDirectory) { + folderPaths.push(filePath) + } else { + fileAttachments.push(filePath) + } + } catch { + // Skip paths we cannot authorize or stat. + } + } + + addComposerAttachments(fileAttachments) + insertComposerFolderPaths(folderPaths) + }, + [addComposerAttachments, insertComposerFolderPaths] + ) + const addComposerAttachmentsRef = useRef(addComposerAttachments) + addComposerAttachmentsRef.current = addComposerAttachments + const insertComposerFolderPathsRef = useRef(insertComposerFolderPaths) + insertComposerFolderPathsRef.current = insertComposerFolderPaths + const uploadComposerPathsRef = useRef(uploadComposerPaths) + uploadComposerPathsRef.current = uploadComposerPaths + const applyLocalComposerDropRef = useRef(applyLocalComposerDrop) + applyLocalComposerDropRef.current = applyLocalComposerDrop // Why: native OS file drops onto the composer are captured by the preload // bridge (see `data-native-file-drop-target="composer"` markers) and relayed @@ -1015,82 +1194,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return } void (async () => { - const fileAttachments: string[] = [] - const folderPaths: string[] = [] - for (const filePath of data.paths) { - try { - await window.api.fs.authorizeExternalPath({ targetPath: filePath }) - const stat = await window.api.fs.stat({ filePath }) - if (stat.isDirectory) { - folderPaths.push(filePath) - } else { - fileAttachments.push(filePath) - } - } catch { - // Skip paths we cannot authorize or stat. - } - } - - if (fileAttachments.length > 0) { - setAttachmentPaths((current) => { - const next = [...current] - for (const p of fileAttachments) { - if (!next.includes(p)) { - next.push(p) - } - } - return next - }) - } - - if (folderPaths.length > 0) { - // Why: de-dup within a single drop — the OS occasionally delivers - // the same folder twice when a user drags from a selection that - // includes both the item and its parent, and we don't want to - // insert it multiple times. - const uniqueFolderPaths = Array.from(new Set(folderPaths)) - // Why: wrap paths containing shell metacharacters in double quotes - // (and escape embedded quotes) so the inserted text reads as a - // single token if the user pastes it into a terminal. Simple paths - // stay unadorned to match how Finder/Explorer drops appear. - const formatPath = (p: string): string => { - if (/[\s"'$`\\()[\]{}*?!;&|<>#~]/.test(p)) { - return `"${p.replace(/(["\\$`])/g, '\\$1')}"` - } - return p - } - const insertion = uniqueFolderPaths.map(formatPath).join(' ') - const textarea = promptTextareaRef.current - // Why: compute selection, insertion, and caret target OUTSIDE the - // setAgentPrompt updater so the updater stays pure. React Strict - // Mode double-invokes updaters in dev, and batching can delay - // execution — reading `textarea.selectionStart` inside the updater - // risks seeing a shifted caret. Read `agentPromptRef.current` for - // the latest prompt because this effect subscribes once and the - // outer closure's `agentPrompt` would be stale. - const current = agentPromptRef.current - const selStart = textarea?.selectionStart ?? current.length - const selEnd = textarea?.selectionEnd ?? current.length - const before = current.slice(0, selStart) - const after = current.slice(selEnd) - // Why: pad with single spaces when the caret sits directly against - // other text so the folder path doesn't merge into an adjacent word. - const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) - const needsTrailingSpace = after.length > 0 && !/^\s/.test(after) - const padded = `${needsLeadingSpace ? ' ' : ''}${insertion}${needsTrailingSpace ? ' ' : ''}` - const caret = before.length + padded.length - if (textarea) { - // Restore the caret to the end of the inserted text after React flushes. - requestAnimationFrame(() => { - textarea.focus() - textarea.setSelectionRange(caret, caret) - }) - } - // Why: pass a plain value (not an updater) since `before`/`after` - // were already resolved from `agentPromptRef.current`; this keeps - // the state write side-effect-free under Strict-Mode double-render. - setAgentPrompt(before + padded + after) + const uploaded = await uploadComposerPathsRef.current( + data.paths, + settingsRef.current, + connectionIdRef.current, + selectedRepoPathRef.current + ) + if (uploaded) { + addComposerAttachmentsRef.current(uploaded.filePaths) + insertComposerFolderPathsRef.current(uploaded.folderPaths) + return } + await applyLocalComposerDropRef.current(data.paths) })() }) return () => { @@ -1231,15 +1346,31 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return } setPushTarget(undefined) - void window.api.worktrees - .resolvePrBase({ - repoId: repoForItem.id, - prNumber: item.number, - ...(item.branchName ? { headRefName: item.branchName } : {}), - ...(item.isCrossRepository !== undefined - ? { isCrossRepository: item.isCrossRepository } - : {}) - }) + const target = getActiveRuntimeTarget(settings) + const resolvePrBase = + target.kind === 'local' + ? window.api.worktrees.resolvePrBase({ + repoId: repoForItem.id, + prNumber: item.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + : callRuntimeRpc<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }>( + target, + 'worktree.resolvePrBase', + { + repo: repoForItem.id, + prNumber: item.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }, + { timeoutMs: 30_000 } + ) + void resolvePrBase .then((result) => { if ('error' in result) { setBaseBranch(undefined) @@ -1255,7 +1386,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS toast.error(error instanceof Error ? error.message : 'Failed to resolve PR base.') }) }, - [applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo] + [applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo, settings] ) // Why: GitLab parallel of handleSmartGitHubItemSelect. For a picked diff --git a/src/renderer/src/hooks/useEditorExternalWatch.test.ts b/src/renderer/src/hooks/useEditorExternalWatch.test.ts index f6f809ff472..1b0b823f6c7 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.test.ts @@ -20,6 +20,10 @@ import { getWatchedTargetKey } from './useEditorExternalWatch' import { useAppStore } from '@/store' +import { + getOpenFilesForExternalFileChange, + notifyEditorExternalFileChange +} from '@/components/editor/editor-autosave' describe('getWatchedTargetKey', () => { it('changes when a worktree gains an SSH connection id', () => { @@ -27,13 +31,33 @@ describe('getWatchedTargetKey', () => { getWatchedTargetKey({ worktreeId: 'wt-1', worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + runtimeEnvironmentId: undefined }) ).not.toBe( getWatchedTargetKey({ worktreeId: 'wt-1', worktreePath: '/repo', - connectionId: 'conn-1' + connectionId: 'conn-1', + runtimeEnvironmentId: undefined + }) + ) + }) + + it('changes when the active runtime environment changes', () => { + expect( + getWatchedTargetKey({ + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: undefined, + runtimeEnvironmentId: undefined + }) + ).not.toBe( + getWatchedTargetKey({ + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: undefined, + runtimeEnvironmentId: 'env-1' }) ) }) @@ -104,10 +128,16 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { worktreeId: string worktreePath: string connectionId: string | undefined + runtimeEnvironmentId: string | undefined } | undefined => worktreePath === '/repo' - ? { worktreeId: 'wt-1', worktreePath: '/repo', connectionId: undefined } + ? { + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: undefined, + runtimeEnvironmentId: undefined + } : undefined const fileNotes = { @@ -182,4 +212,76 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { dispose() }) + + it('reloads Windows editor tabs when watcher event casing differs', () => { + const file = { + id: 'file-win', + worktreeId: 'wt-win', + worktreePath: 'C:\\Repo', + filePath: 'C:\\Repo\\notes.md', + relativePath: 'notes.md', + mode: 'edit' as const, + isDirty: false + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [file], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ + worktreeId: 'wt-win', + worktreePath: 'C:\\Repo', + connectionId: undefined, + runtimeEnvironmentId: 'env-1' + })) + + handleFsChanged({ + worktreePath: 'c:\\repo', + events: [{ kind: 'update', absolutePath: 'c:\\repo\\notes.md', isDirectory: false }] + }) + vi.advanceTimersByTime(100) + + expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({ + worktreeId: 'wt-win', + worktreePath: 'C:\\Repo', + relativePath: 'notes.md' + }) + dispose() + }) + + it('reloads UNC editor tabs without collapsing the share root', () => { + const file = { + id: 'file-unc', + worktreeId: 'wt-unc', + worktreePath: '//Server/Share/Repo', + filePath: '//Server/Share/Repo/notes.md', + relativePath: 'notes.md', + mode: 'edit' as const, + isDirty: false + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [file], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ + worktreeId: 'wt-unc', + worktreePath: '//Server/Share/Repo', + connectionId: undefined, + runtimeEnvironmentId: 'env-1' + })) + + handleFsChanged({ + worktreePath: '//server/share/repo', + events: [{ kind: 'update', absolutePath: '//server/share/repo/notes.md', isDirectory: false }] + }) + vi.advanceTimersByTime(100) + + expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({ + worktreeId: 'wt-unc', + worktreePath: '//Server/Share/Repo', + relativePath: 'notes.md' + }) + dispose() + }) }) diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 26e97953c1f..d6d1081837b 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -5,8 +5,8 @@ import { useEffect, useMemo, useRef } from 'react' import { useAppStore } from '@/store' import { basename, joinPath } from '@/lib/path' -import { normalizeAbsolutePath } from '@/components/right-sidebar/file-explorer-paths' import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { getOpenFilesForExternalFileChange, notifyEditorExternalFileChange @@ -15,6 +15,7 @@ import { hasRecentSelfWrite } from '@/components/editor/editor-self-write-regist import type { FsChangedPayload } from '../../../shared/types' import { findWorktreeById } from '@/store/slices/worktree-helpers' import type { OpenFile } from '@/store/slices/editor' +import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' // Why: atomic-write patterns (Claude Code's Edit tool, editors like vim, // VSCode) land as a short burst of `update` events — or `delete + create` on @@ -25,7 +26,7 @@ import type { OpenFile } from '@/store/slices/editor' // and black out the window (issue #826). Coalescing per (worktreeId + path) // on a short debounce collapses that burst into one reload notification. const EXTERNAL_RELOAD_DEBOUNCE_MS = 75 -const pendingExternalReloadTimers = new Map() +const pendingExternalReloadTimers = new Map>() function warnExternalWatchFailure(target: WatchedTarget, err: unknown): void { console.warn('[filesystem-watch] failed to watch worktree', { @@ -44,9 +45,9 @@ function scheduleDebouncedExternalReload(notification: { const key = `${notification.worktreeId}::${notification.relativePath}` const existing = pendingExternalReloadTimers.get(key) if (existing !== undefined) { - window.clearTimeout(existing) + globalThis.clearTimeout(existing) } - const handle = window.setTimeout(() => { + const handle = globalThis.setTimeout(() => { pendingExternalReloadTimers.delete(key) notifyEditorExternalFileChange(notification) }, EXTERNAL_RELOAD_DEBOUNCE_MS) @@ -57,6 +58,7 @@ type WatchedTarget = { worktreeId: string worktreePath: string connectionId: string | undefined + runtimeEnvironmentId: string | undefined } type ExternalWatchNotification = { @@ -69,7 +71,7 @@ export function getWatchedTargetKey(target: WatchedTarget): string { // Why: SSH worktrees can exist in the store before their remote filesystem // provider is ready. Include connectionId so a local/unknown placeholder // watch is replaced by the real SSH watch when the repo metadata hydrates. - return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}` + return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}::${target.runtimeEnvironmentId ?? 'client'}` } // Why: macOS atomic writes (Claude Code Edit, vim :w, VSCode save) deliver a @@ -106,6 +108,7 @@ export function useEditorExternalWatch(): void { const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const repos = useAppStore((s) => s.repos) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const runtimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) // Why: unify the target computation and the dependency key into one memo so // there's a single source of truth. The derived string key drives the @@ -134,17 +137,20 @@ export function useEditorExternalWatch(): void { const target = { worktreeId: id, worktreePath: wt.path, - connectionId: repo?.connectionId ?? undefined + connectionId: repo?.connectionId ?? undefined, + runtimeEnvironmentId: runtimeEnvironmentId?.trim() || undefined } nextTargets.push(target) parts.push(getWatchedTargetKey(target)) } return { targets: nextTargets, targetsKey: parts.join('|') } - }, [openFiles, worktreesByRepo, repos, activeWorktreeId]) + }, [openFiles, worktreesByRepo, repos, activeWorktreeId, runtimeEnvironmentId]) const targetsRef = useRef([]) const latestTargetsRef = useRef(targets) latestTargetsRef.current = targets + const remoteWatchUnsubsRef = useRef(new Map void>()) + const fsChangedHandlerRef = useRef<((payload: FsChangedPayload) => void) | null>(null) // Why: diff previous vs next targets so unchanged worktrees keep their // existing subscription. Tearing down every subscription on each targetsKey @@ -159,12 +165,55 @@ export function useEditorExternalWatch(): void { const added = nextTargets.filter((t) => !prevKeys.has(getWatchedTargetKey(t))) for (const target of removed) { - void window.api.fs.unwatchWorktree({ - worktreePath: target.worktreePath, - connectionId: target.connectionId - }) + const key = getWatchedTargetKey(target) + const remoteUnsubscribe = remoteWatchUnsubsRef.current.get(key) + if (remoteUnsubscribe) { + remoteUnsubscribe() + remoteWatchUnsubsRef.current.delete(key) + } else { + void window.api.fs.unwatchWorktree({ + worktreePath: target.worktreePath, + connectionId: target.connectionId + }) + } } for (const target of added) { + if (target.runtimeEnvironmentId) { + const key = getWatchedTargetKey(target) + let cancelled = false + const pendingUnsubscribe = (): void => { + cancelled = true + } + remoteWatchUnsubsRef.current.set(key, pendingUnsubscribe) + void subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: target.runtimeEnvironmentId }, + worktreeId: target.worktreeId, + worktreePath: target.worktreePath, + connectionId: target.connectionId + }, + (payload) => fsChangedHandlerRef.current?.(payload), + (err) => warnExternalWatchFailure(target, err) + ) + .then((unsubscribe) => { + if (cancelled) { + unsubscribe() + return + } + if (remoteWatchUnsubsRef.current.get(key) === pendingUnsubscribe) { + remoteWatchUnsubsRef.current.set(key, unsubscribe) + } else { + unsubscribe() + } + }) + .catch((err) => { + if (remoteWatchUnsubsRef.current.get(key) === pendingUnsubscribe) { + remoteWatchUnsubsRef.current.delete(key) + } + warnExternalWatchFailure(target, err) + }) + continue + } void window.api.fs .watchWorktree({ worktreePath: target.worktreePath, @@ -188,25 +237,37 @@ export function useEditorExternalWatch(): void { // single always-mounted effect avoids re-subscribing on every targetsKey // change (which would otherwise miss events fired during re-subscription). useEffect(() => { + const remoteWatchUnsubs = remoteWatchUnsubsRef.current const { handleFsChanged, dispose } = createExternalWatchEventHandler((worktreePath) => targetsRef.current.find( - (t) => normalizeAbsolutePath(t.worktreePath) === normalizeAbsolutePath(worktreePath) + (t) => + normalizeRuntimePathForComparison(t.worktreePath) === + normalizeRuntimePathForComparison(worktreePath) ) ) const unsubscribe = window.api.fs.onFsChanged(handleFsChanged) + fsChangedHandlerRef.current = handleFsChanged return () => { unsubscribe() dispose() + fsChangedHandlerRef.current = null // Why: final unmount must tear down every outstanding subscription. // The differential watch effect above intentionally never unwatches on // cleanup, so this is the only place that clears them. for (const target of targetsRef.current) { - void window.api.fs.unwatchWorktree({ - worktreePath: target.worktreePath, - connectionId: target.connectionId - }) + const key = getWatchedTargetKey(target) + const remoteUnsubscribe = remoteWatchUnsubs.get(key) + if (remoteUnsubscribe) { + remoteUnsubscribe() + } else { + void window.api.fs.unwatchWorktree({ + worktreePath: target.worktreePath, + connectionId: target.connectionId + }) + } } + remoteWatchUnsubs.clear() targetsRef.current = [] // Why: deliberately do NOT clear pendingExternalReloadTimers here. // The map is module-scoped, so in React StrictMode (dev) the first @@ -253,7 +314,7 @@ export function createExternalWatchEventHandler( continue } if (evt.kind === 'create' || evt.kind === 'update') { - createOrUpdatePaths.add(normalizeAbsolutePath(evt.absolutePath)) + createOrUpdatePaths.add(normalizeRuntimePathForComparison(evt.absolutePath)) } } for (const createdPath of createOrUpdatePaths) { @@ -346,7 +407,7 @@ export function createExternalWatchEventHandler( file.worktreeId === target.worktreeId && (file.mode === 'edit' || file.mode === 'markdown-preview') && file.externalMutation && - createOrUpdatePaths.has(normalizeAbsolutePath(file.filePath)) + createOrUpdatePaths.has(normalizeRuntimePathForComparison(file.filePath)) ) { state.setExternalMutation(file.id, null) } @@ -384,7 +445,7 @@ export function createExternalWatchEventHandler( const relativePath = getExternalFileChangeRelativePath( target.worktreePath, - normalizeAbsolutePath(evt.absolutePath), + evt.absolutePath, evt.isDirectory ) if (relativePath) { @@ -481,7 +542,7 @@ function buildDeletePathByFileId( const deletePaths = new Set() for (const evt of payload.events) { if (evt.kind === 'delete') { - deletePaths.add(normalizeAbsolutePath(evt.absolutePath)) + deletePaths.add(normalizeRuntimePathForComparison(evt.absolutePath)) } } const result = new Map() @@ -493,7 +554,7 @@ function buildDeletePathByFileId( if (!deletedIdSet.has(file.id) || file.worktreeId !== worktreeId) { continue } - const normalized = normalizeAbsolutePath(file.filePath) + const normalized = normalizeRuntimePathForComparison(file.filePath) if (deletePaths.has(normalized)) { result.set(file.id, normalized) } @@ -509,7 +570,7 @@ function collectDeletedOpenEditorIds( const deletePaths = new Set() for (const evt of payload.events) { if (evt.kind === 'delete') { - deletePaths.add(normalizeAbsolutePath(evt.absolutePath)) + deletePaths.add(normalizeRuntimePathForComparison(evt.absolutePath)) } } if (deletePaths.size === 0) { @@ -523,7 +584,7 @@ function collectDeletedOpenEditorIds( ) { continue } - if (deletePaths.has(normalizeAbsolutePath(file.filePath))) { + if (deletePaths.has(normalizeRuntimePathForComparison(file.filePath))) { result.push(file.id) } } @@ -564,7 +625,7 @@ function hasRenameCorrelatedCreate( if (!deletedIdSet.has(file.id)) { continue } - deletedBasenames.add(basename(normalizeAbsolutePath(file.filePath))) + deletedBasenames.add(basename(file.filePath)) } if (deletedBasenames.size === 0) { return false @@ -573,7 +634,7 @@ function hasRenameCorrelatedCreate( if (evt.kind !== 'create' || evt.isDirectory === true) { continue } - if (deletedBasenames.has(basename(normalizeAbsolutePath(evt.absolutePath)))) { + if (deletedBasenames.has(basename(evt.absolutePath))) { return true } } diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.ts b/src/renderer/src/hooks/useGitHubSlugMetadata.ts index 06861899518..49decd64ec5 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.ts +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.ts @@ -5,7 +5,12 @@ // existing repoPath-keyed hooks stay focused on the local-workspace flow // and so this file remains under the lint line cap. import { useEffect, useRef, useState } from 'react' -import type { GitHubAssignableUser } from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { GitHubAssignableUser, GlobalSettings } from '../../../shared/types' +import type { + ListAssignableUsersBySlugResult, + ListLabelsBySlugResult +} from '../../../shared/github-project-types' import { clearMetadataRequestStore, createMetadataRequestStore, @@ -29,7 +34,8 @@ export function clearGitHubSlugMetadataCache(): void { export function useRepoLabelsBySlug( owner: string | null, - repo: string | null + repo: string | null, + settings?: Pick | null ): MetadataState { const [state, setState] = useState>({ data: [], @@ -42,7 +48,11 @@ export function useRepoLabelsBySlug( if (!owner || !repo) { return } - const key = `${owner}/${repo}` + const target = getActiveRuntimeTarget(settings) + const key = + target.kind === 'environment' + ? `runtime:${target.environmentId}:${owner}/${repo}` + : `${owner}/${repo}` const cached = getFreshMetadata(slugLabelStore, key) if (cached) { @@ -64,7 +74,15 @@ export function useRepoLabelsBySlug( error: null })) loadMetadata(slugLabelStore, key, () => - window.api.gh.listLabelsBySlug({ owner, repo }).then((res) => { + (target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.project.listLabelsBySlug', + { owner, repo }, + { timeoutMs: 30_000 } + ) + : window.api.gh.listLabelsBySlug({ owner, repo }) + ).then((res) => { if (!res.ok) { throw new Error(res.error.message) } @@ -88,7 +106,7 @@ export function useRepoLabelsBySlug( error: err instanceof Error ? err.message : 'Failed to load labels' })) }) - }, [owner, repo]) + }, [owner, repo, settings]) return state } @@ -96,7 +114,8 @@ export function useRepoLabelsBySlug( export function useRepoAssigneesBySlug( owner: string | null, repo: string | null, - seedLogins?: string[] + seedLogins?: string[], + settings?: Pick | null ): MetadataState { const [state, setState] = useState>({ data: [], @@ -113,7 +132,11 @@ export function useRepoAssigneesBySlug( if (!owner || !repo) { return } - const key = `${owner}/${repo}#${seedKey}` + const target = getActiveRuntimeTarget(settings) + const key = + target.kind === 'environment' + ? `runtime:${target.environmentId}:${owner}/${repo}#${seedKey}` + : `${owner}/${repo}#${seedKey}` const cached = getFreshMetadata(slugAssigneeStore, key) if (cached) { @@ -133,19 +156,26 @@ export function useRepoAssigneesBySlug( loading: true, error: null })) + const args = { + owner, + repo, + ...(seedKey ? { seedLogins: seedKey.split(',') } : {}) + } loadMetadata(slugAssigneeStore, key, () => - window.api.gh - .listAssignableUsersBySlug({ - owner, - repo, - ...(seedKey ? { seedLogins: seedKey.split(',') } : {}) - }) - .then((res) => { - if (!res.ok) { - throw new Error(res.error.message) - } - return res.users - }) + (target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.project.listAssignableUsersBySlug', + args, + { timeoutMs: 30_000 } + ) + : window.api.gh.listAssignableUsersBySlug(args) + ).then((res) => { + if (!res.ok) { + throw new Error(res.error.message) + } + return res.users + }) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -164,7 +194,7 @@ export function useRepoAssigneesBySlug( error: err instanceof Error ? err.message : 'Failed to load assignees' })) }) - }, [owner, repo, seedKey]) + }, [owner, repo, seedKey, settings]) return state } diff --git a/src/renderer/src/hooks/useGlobalFileDrop.test.ts b/src/renderer/src/hooks/useGlobalFileDrop.test.ts new file mode 100644 index 00000000000..1439e401c69 --- /dev/null +++ b/src/renderer/src/hooks/useGlobalFileDrop.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { shouldUploadRemoteEditorFileDrop } from './useGlobalFileDrop' + +describe('shouldUploadRemoteEditorFileDrop', () => { + it('does not upload editor drops for local workspaces', () => { + expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, null)).toBe(false) + }) + + it('uploads editor drops while a runtime environment is active', () => { + expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: 'env-1' }, null)).toBe( + true + ) + }) + + it('uploads editor drops for SSH workspaces', () => { + expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe( + true + ) + }) +}) diff --git a/src/renderer/src/hooks/useGlobalFileDrop.ts b/src/renderer/src/hooks/useGlobalFileDrop.ts index b5020e94271..4f0564fee00 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.ts @@ -1,8 +1,24 @@ import { useEffect } from 'react' +import { toast } from 'sonner' import { detectLanguage } from '@/lib/language-detect' import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' +import { joinPath } from '@/lib/path' +import { + importExternalPathsToRuntime, + isRemoteRuntimeFileOperation, + statRuntimePath, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' +import type { GlobalSettings } from '../../../shared/types' + +export function shouldUploadRemoteEditorFileDrop( + settings: Pick | null | undefined, + connectionId: string | null | undefined +): boolean { + return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || connectionId?.trim()) +} export function useGlobalFileDrop(): void { useEffect(() => { @@ -19,6 +35,58 @@ export function useGlobalFileDrop(): void { const activeWorktree = store.allWorktrees().find((w) => w.id === activeWorktreeId) const worktreePath = activeWorktree?.path + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const dropSettings = store.settings + const runtimeEnvironmentId = dropSettings?.activeRuntimeEnvironmentId?.trim() || undefined + if (shouldUploadRemoteEditorFileDrop(dropSettings, connectionId)) { + if (!worktreePath) { + toast.error('No remote workspace path is available for dropped files.') + return + } + void (async () => { + try { + // Why: OS file drops provide client-local paths. Remote runtime and + // SSH editors must upload into the server worktree before opening. + const destinationDir = joinPath(worktreePath, '.orca/drops') + const { results } = await importExternalPathsToRuntime( + { + settings: dropSettings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + data.paths, + destinationDir, + { ensureDestinationDir: true } + ) + const imported = results.filter((result) => result.status === 'imported') + for (const result of imported) { + if (result.kind === 'directory') { + continue + } + const maybeRelative = toWorktreeRelativePath(result.destPath, worktreePath) + store.setActiveTabType('editor') + store.openFile( + { + filePath: result.destPath, + relativePath: maybeRelative ?? result.destPath, + worktreeId: activeWorktreeId, + runtimeEnvironmentId, + language: detectLanguage(result.destPath), + mode: 'edit' + }, + { suppressActiveRuntimeFallback: runtimeEnvironmentId === undefined } + ) + } + if (results.some((result) => result.status !== 'imported')) { + toast.error('Some dropped files could not be uploaded.') + } + } catch { + toast.error('Failed to upload dropped files.') + } + })() + return + } // Why: the relay payload now sends all paths in one gesture-scoped event. // Loop over every dropped file so multi-file editor drops still open @@ -26,12 +94,18 @@ export function useGlobalFileDrop(): void { for (const filePath of data.paths) { void (async () => { try { - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - // Why: remote paths don't need local auth — the relay is the security boundary. - if (!connectionId) { + const fileContext: RuntimeFileOperationArgs = { + settings: store.settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + } + const isRemoteRuntimePath = isRemoteRuntimeFileOperation(fileContext, filePath) + // Why: remote paths don't need local auth — the relay/runtime is the security boundary. + if (!connectionId && !isRemoteRuntimePath) { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) } - const stat = await window.api.fs.stat({ filePath, connectionId }) + const stat = await statRuntimePath(fileContext, filePath) if (stat.isDirectory) { return } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 65ee868a261..ada47027c8e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -40,6 +40,10 @@ export { resolveZoomTarget } from './resolve-zoom-target' const ZOOM_STEP = 0.5 +function isRuntimeEnvironmentActive(): boolean { + return Boolean(useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) +} + export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] @@ -48,12 +52,23 @@ export function useIpcEvents(): void { unsubs.push( window.api.repos.onChanged(() => { + if (isRuntimeEnvironmentActive()) { + // Why: this event comes from the local Electron store. While a + // runtime server is selected, repo hydration must be driven by the + // selected server instead of local-disk changes. + return + } useAppStore.getState().fetchRepos() }) ) unsubs.push( window.api.worktrees.onChanged(async (data: { repoId: string }) => { + if (isRuntimeEnvironmentActive()) { + // Why: local worktree events carry local repo ids. Fetching the + // active runtime with those ids can purge or overwrite server state. + return + } // Why: diff before vs. after fetchWorktrees to detect server-side // deletions (CLI `orca worktree rm`, other window, out-of-band RPC) // and purge worktree-scoped state for removed ids. Without this, @@ -83,12 +98,18 @@ export function useIpcEvents(): void { unsubs.push( window.api.worktrees.onBaseStatus((event) => { + if (isRuntimeEnvironmentActive()) { + return + } useAppStore.getState().updateWorktreeBaseStatus(event) }) ) unsubs.push( window.api.worktrees.onRemoteBranchConflict((event) => { + if (isRuntimeEnvironmentActive()) { + return + } useAppStore.getState().updateWorktreeRemoteBranchConflict(event) }) ) @@ -230,6 +251,12 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup }) => { void (async () => { + if (isRuntimeEnvironmentActive()) { + // Why: local CLI-created worktree events carry local repo/worktree + // ids. Runtime server activation arrives through runtime state, + // not this local Electron event. + return + } // Why: fetch worktrees first so the activation helper can resolve // the CLI-created worktree via findWorktreeById — it arrived from // the main process and is not yet in the renderer state. @@ -250,6 +277,15 @@ export function useIpcEvents(): void { window.api.ui.onCreateTerminal( ({ requestId, worktreeId, command, title, ptyId, activate, tabId }) => { try { + if (isRuntimeEnvironmentActive()) { + if (requestId) { + window.api.ui.replyTerminalCreate({ + requestId, + error: 'Local terminal reveal is unavailable while a remote runtime is active' + }) + } + return + } const store = useAppStore.getState() const shouldActivate = activate !== false if (shouldActivate) { @@ -324,6 +360,13 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onRequestTerminalCreate((data) => { try { + if (isRuntimeEnvironmentActive()) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: 'Local terminal creation is unavailable while a remote runtime is active' + }) + return + } const store = useAppStore.getState() const worktreeId = data.worktreeId ?? store.activeWorktreeId if (!worktreeId) { @@ -510,6 +553,9 @@ export function useIpcEvents(): void { unsubs.push( window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => { + if (isRuntimeEnvironmentActive()) { + return + } useAppStore.getState().updateBrowserPageState(browserPageId, { loading: false, loadError, @@ -525,6 +571,9 @@ export function useIpcEvents(): void { // This IPC pushes the live URL/title from main after goto/click/back/reload. unsubs.push( window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => { + if (isRuntimeEnvironmentActive()) { + return + } const store = useAppStore.getState() store.setBrowserPageUrl(browserPageId, url) store.updateBrowserPageState(browserPageId, { title, loading: false }) @@ -537,6 +586,9 @@ export function useIpcEvents(): void { // before browser commands so the webview can start and registerGuest fires. unsubs.push( window.api.browser.onActivateView(() => { + if (isRuntimeEnvironmentActive()) { + return + } useAppStore.getState().setActiveTabType('browser') }) ) @@ -552,6 +604,9 @@ export function useIpcEvents(): void { // pre-staging for whenever the user next visits that worktree. unsubs.push( window.api.browser.onPaneFocus(({ worktreeId, browserPageId }) => { + if (isRuntimeEnvironmentActive()) { + return + } const store = useAppStore.getState() // Why: main sends `worktreeId: null` if the tab closed between the // bridge resolving tabSwitch and getWorktreeIdForTab running. Falling @@ -568,6 +623,9 @@ export function useIpcEvents(): void { unsubs.push( window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => { + if (isRuntimeEnvironmentActive()) { + return + } const store = useAppStore.getState() const sourcePage = Object.values(store.browserPagesByWorkspace) .flat() @@ -587,6 +645,9 @@ export function useIpcEvents(): void { // capture keyboard focus and bypass the renderer's window-level keydown. unsubs.push( window.api.ui.onNewBrowserTab(() => { + if (isRuntimeEnvironmentActive()) { + return + } const store = useAppStore.getState() const worktreeId = store.activeWorktreeId if (worktreeId) { @@ -604,6 +665,15 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onRequestTabCreate((data) => { try { + if (isRuntimeEnvironmentActive()) { + // Why: browser automation targets client-local Electron webviews. + // Runtime agents cannot see or control those surfaces. + window.api.ui.replyTabCreate({ + requestId: data.requestId, + error: 'Browser tabs are unavailable while a remote runtime is active' + }) + return + } const store = useAppStore.getState() const worktreeId = data.worktreeId ?? store.activeWorktreeId if (!worktreeId) { @@ -643,6 +713,13 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onRequestTabSetProfile((data) => { try { + if (isRuntimeEnvironmentActive()) { + window.api.ui.replyTabSetProfile({ + requestId: data.requestId, + error: 'Browser profiles are unavailable while a remote runtime is active' + }) + return + } const store = useAppStore.getState() const owningWorkspace = Object.values(store.browserTabsByWorktree) .flat() @@ -684,6 +761,13 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onRequestTabClose((data) => { try { + if (isRuntimeEnvironmentActive()) { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: 'Browser tabs are unavailable while a remote runtime is active' + }) + return + } const store = useAppStore.getState() const explicitTargetId = data.tabId ?? null let tabToClose = @@ -1121,12 +1205,17 @@ export function useIpcEvents(): void { // Why: hydrate mobile-fit overrides before terminal panes run their first // attach/fit logic, so a renderer reload doesn't undo active mobile fits. - void window.api.runtime.getTerminalFitOverrides().then((overrides) => { - hydrateOverrides(overrides) - }) + if (!isRuntimeEnvironmentActive()) { + void window.api.runtime.getTerminalFitOverrides().then((overrides) => { + hydrateOverrides(overrides) + }) + } unsubs.push( window.api.runtime.onTerminalFitOverrideChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } setFitOverride(event.ptyId, event.mode, event.cols, event.rows) }) ) @@ -1137,6 +1226,9 @@ export function useIpcEvents(): void { // know which PTYs are currently driven by mobile. See // docs/mobile-presence-lock.md. window.api.runtime.onTerminalDriverChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } setDriverForPty(event.ptyId, event.driver) }) ) diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index c715d6cb550..deec6cb6a9b 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -1,6 +1,15 @@ +/* eslint-disable max-lines -- Why: repo metadata hooks share TTL caches and +Linear/GitHub cache invalidation entrypoints used by the issue dialog. */ import { useEffect, useRef, useState } from 'react' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + linearTeamLabels, + linearTeamMembers, + linearTeamStates +} from '@/runtime/runtime-linear-client' import type { GitHubAssignableUser, + GlobalSettings, LinearWorkflowState, LinearLabel, LinearMember @@ -23,7 +32,11 @@ type MetadataState = { const ghLabelStore = createMetadataRequestStore() const ghAssigneeStore = createMetadataRequestStore() -export function useRepoLabels(repoPath: string | null): MetadataState { +export function useRepoLabels( + repoPath: string | null, + repoId?: string | null, + settings?: Pick | null +): MetadataState { const [state, setState] = useState>({ data: [], loading: false, @@ -32,29 +45,41 @@ export function useRepoLabels(repoPath: string | null): MetadataState const activeKeyRef = useRef(null) useEffect(() => { - if (!repoPath) { + const target = getActiveRuntimeTarget(settings) + if (!repoPath && !(target.kind === 'environment' && repoId)) { return } + const cacheKey = + target.kind === 'environment' ? `runtime:${target.environmentId}:${repoId}` : (repoPath ?? '') - const cached = getFreshMetadata(ghLabelStore, repoPath) + const cached = getFreshMetadata(ghLabelStore, cacheKey) if (cached) { - if (activeKeyRef.current !== repoPath) { + if (activeKeyRef.current !== cacheKey) { setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = repoPath + activeKeyRef.current = cacheKey } return } - activeKeyRef.current = repoPath - const requestKey = repoPath + activeKeyRef.current = cacheKey + const requestKey = cacheKey setState((s) => ({ ...s, data: s.data.length ? ([] as typeof s.data) : s.data, loading: true, error: null })) - loadMetadata(ghLabelStore, repoPath, () => - window.api.gh.listLabels({ repoPath }).then((labels) => labels as string[]) + loadMetadata(ghLabelStore, cacheKey, () => + target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.listLabels', + { repo: repoId ?? '' }, + { timeoutMs: 30_000 } + ) + : window.api.gh + .listLabels({ repoPath: repoPath ?? '' }) + .then((labels) => labels as string[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -73,12 +98,16 @@ export function useRepoLabels(repoPath: string | null): MetadataState error: err instanceof Error ? err.message : 'Failed to load labels' })) }) - }, [repoPath]) + }, [repoId, repoPath, settings]) return state } -export function useRepoAssignees(repoPath: string | null): MetadataState { +export function useRepoAssignees( + repoPath: string | null, + repoId?: string | null, + settings?: Pick | null +): MetadataState { const [state, setState] = useState>({ data: [], loading: false, @@ -87,31 +116,41 @@ export function useRepoAssignees(repoPath: string | null): MetadataState(null) useEffect(() => { - if (!repoPath) { + const target = getActiveRuntimeTarget(settings) + if (!repoPath && !(target.kind === 'environment' && repoId)) { return } + const cacheKey = + target.kind === 'environment' ? `runtime:${target.environmentId}:${repoId}` : (repoPath ?? '') - const cached = getFreshMetadata(ghAssigneeStore, repoPath) + const cached = getFreshMetadata(ghAssigneeStore, cacheKey) if (cached) { - if (activeKeyRef.current !== repoPath) { + if (activeKeyRef.current !== cacheKey) { setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = repoPath + activeKeyRef.current = cacheKey } return } - activeKeyRef.current = repoPath - const requestKey = repoPath + activeKeyRef.current = cacheKey + const requestKey = cacheKey setState((s) => ({ ...s, data: s.data.length ? ([] as typeof s.data) : s.data, loading: true, error: null })) - loadMetadata(ghAssigneeStore, repoPath, () => - window.api.gh - .listAssignableUsers({ repoPath }) - .then((users) => users as GitHubAssignableUser[]) + loadMetadata(ghAssigneeStore, cacheKey, () => + target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.listAssignableUsers', + { repo: repoId ?? '' }, + { timeoutMs: 30_000 } + ) + : window.api.gh + .listAssignableUsers({ repoPath: repoPath ?? '' }) + .then((users) => users as GitHubAssignableUser[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -130,7 +169,7 @@ export function useRepoAssignees(repoPath: string | null): MetadataState() const linearLabelStore = createMetadataRequestStore() const linearMemberStore = createMetadataRequestStore() +function linearMetadataCacheKey( + teamId: string, + settings: Pick | null | undefined +): string { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}:${teamId}` : teamId +} + export function clearLinearMetadataCache(): void { clearMetadataRequestStore(linearStateStore) clearMetadataRequestStore(linearLabelStore) @@ -152,7 +199,10 @@ export function clearGitHubMetadataCache(): void { clearMetadataRequestStore(ghAssigneeStore) } -export function useTeamStates(teamId: string | null): MetadataState { +export function useTeamStates( + teamId: string | null, + settings?: Pick | null +): MetadataState { const [state, setState] = useState>({ data: [], loading: false, @@ -165,25 +215,26 @@ export function useTeamStates(teamId: string | null): MetadataState ({ ...s, data: s.data.length ? ([] as typeof s.data) : s.data, loading: true, error: null })) - loadMetadata(linearStateStore, teamId, () => - window.api.linear.teamStates({ teamId }).then((states) => states as LinearWorkflowState[]) + loadMetadata(linearStateStore, cacheKey, () => + linearTeamStates(settings, teamId).then((states) => states as LinearWorkflowState[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -202,12 +253,15 @@ export function useTeamStates(teamId: string | null): MetadataState { +export function useTeamLabels( + teamId: string | null, + settings?: Pick | null +): MetadataState { const [state, setState] = useState>({ data: [], loading: false, @@ -220,25 +274,26 @@ export function useTeamLabels(teamId: string | null): MetadataState ({ ...s, data: s.data.length ? ([] as typeof s.data) : s.data, loading: true, error: null })) - loadMetadata(linearLabelStore, teamId, () => - window.api.linear.teamLabels({ teamId }).then((labels) => labels as LinearLabel[]) + loadMetadata(linearLabelStore, cacheKey, () => + linearTeamLabels(settings, teamId).then((labels) => labels as LinearLabel[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -257,12 +312,15 @@ export function useTeamLabels(teamId: string | null): MetadataState { +export function useTeamMembers( + teamId: string | null, + settings?: Pick | null +): MetadataState { const [state, setState] = useState>({ data: [], loading: false, @@ -275,25 +333,26 @@ export function useTeamMembers(teamId: string | null): MetadataState ({ ...s, data: s.data.length ? ([] as typeof s.data) : s.data, loading: true, error: null })) - loadMetadata(linearMemberStore, teamId, () => - window.api.linear.teamMembers({ teamId }).then((members) => members as LinearMember[]) + loadMetadata(linearMemberStore, cacheKey, () => + linearTeamMembers(settings, teamId).then((members) => members as LinearMember[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -312,7 +371,7 @@ export function useTeamMembers(teamId: string | null): MetadataState finish(true), BRACKETED_PASTE_QUIET_MS) } - unsubscribe = subscribeToPtyData(ptyId, (data) => { + const observeData = (data: string): void => { // Why: keep just enough recent bytes that an escape sequence split // across two IPC frames is still detectable. 64 bytes >> 8-byte // sequence; cheap and bounded. @@ -151,7 +154,26 @@ function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise { + if (settled) { + remoteUnsubscribe() + return + } + unsubscribe = remoteUnsubscribe + }) + .catch(() => finish(false)) + } else { + unsubscribe = subscribeToPtyData(ptyId, observeData) + } const hardTimer = window.setTimeout(() => finish(false), timeoutMs) }) diff --git a/src/renderer/src/lib/agent-ready-wait.ts b/src/renderer/src/lib/agent-ready-wait.ts index 70af9f83910..84b75c721f9 100644 --- a/src/renderer/src/lib/agent-ready-wait.ts +++ b/src/renderer/src/lib/agent-ready-wait.ts @@ -1,6 +1,7 @@ import { detectAgentStatusFromTitle } from '../../../shared/agent-detection' import { isShellProcess } from '@/lib/tui-agent-startup' import { useAppStore } from '@/store' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' // Why: agent CLIs vary widely in how they signal readiness. Title-based // detection (OSC titles parsed by detectAgentStatusFromTitle) is the tightest @@ -89,7 +90,8 @@ export async function waitForAgentReady( } try { - const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? '' + const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId) + const foreground = process.foregroundProcess?.toLowerCase() ?? '' if ( foreground === expectedProcess || foreground.startsWith(`${expectedProcess}.`) || @@ -103,8 +105,7 @@ export async function waitForAgentReady( // polls so the shell's own startup children don't spoof readiness on // cold-start. Never accept it while the foreground is still a shell. if (attempt >= 4 && !isShellProcess(foreground)) { - const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId) - if (hasChildProcesses) { + if (process.hasChildProcesses) { return { ready: true, reason: 'child-process' } } } diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index e32c733b54c..e3ec0811c8e 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { markLiveCodexSessionsForRestart } from './codex-session-restart' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const ACCOUNT_A = 'account-a@example.com' const ACCOUNT_B = 'account-b@example.com' @@ -8,8 +13,16 @@ const ACCOUNT_C = 'account-c@example.com' describe('markLiveCodexSessionsForRestart', () => { const originalWindow = (globalThis as { window?: typeof window }).window + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) useAppStore.setState({ tabsByWorktree: { wt1: [ @@ -39,7 +52,12 @@ describe('markLiveCodexSessionsForRestart', () => { ...originalWindow?.api, pty: { ...originalWindow?.api?.pty, - getForegroundProcess: vi.fn() + getForegroundProcess: vi.fn(), + hasChildProcesses: vi.fn().mockResolvedValue(false) + }, + runtimeEnvironments: { + ...originalWindow?.api?.runtimeEnvironments, + call: runtimeEnvironmentTransportCall } } } as unknown as typeof window @@ -143,4 +161,52 @@ describe('markLiveCodexSessionsForRestart', () => { nextAccountLabel: ACCOUNT_C }) }) + + it('inspects remote runtime PTYs through the active runtime environment', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + tabsByWorktree: { + wt1: [ + { + id: 'tab-1', + ptyId: 'remote:term-1', + worktreeId: 'wt1', + title: 'orca-1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { + 'tab-1': ['remote:term-1'] + } + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { + process: { foregroundProcess: 'codex', hasChildProcesses: true } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(window.api.pty.getForegroundProcess).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.inspectProcess', + params: { terminal: 'term-1' }, + timeoutMs: 15_000 + }) + expect(useAppStore.getState().codexRestartNoticeByPtyId['remote:term-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + }) }) diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index a3a71f2d669..6e7a46bb156 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -1,5 +1,6 @@ import type { AppState } from '@/store' import { useAppStore } from '@/store' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' function normalizeProcessName(processName: string | null): string | null { if (!processName) { @@ -34,7 +35,11 @@ async function getLiveCodexSessionPtyIds(state: AppState): Promise { // does not always do that. The foreground PTY process is the stable // source of truth for whether this live tab is actually running Codex. const foregroundProcesses = await Promise.all( - ptyIds.map((ptyId) => window.api.pty.getForegroundProcess(ptyId)) + ptyIds.map((ptyId) => + inspectRuntimeTerminalProcess(state.settings, ptyId).then( + (inspection) => inspection.foregroundProcess + ) + ) ) return ptyIds.filter((_, index) => isCodexForegroundProcess(foregroundProcesses[index])) }) diff --git a/src/renderer/src/lib/create-untitled-markdown.test.ts b/src/renderer/src/lib/create-untitled-markdown.test.ts index 0b43bfcea72..e5768ffede4 100644 --- a/src/renderer/src/lib/create-untitled-markdown.test.ts +++ b/src/renderer/src/lib/create-untitled-markdown.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUntitledMarkdownFile } from './create-untitled-markdown' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' describe('createUntitledMarkdownFile', () => { afterEach(() => { @@ -7,7 +12,13 @@ describe('createUntitledMarkdownFile', () => { }) it('retries with the next untitled name when createFile loses the EEXIST race', async () => { - const pathExists = vi.fn(async (filePath: string) => filePath.endsWith('untitled.md')) + const pathExists = vi.fn() + const stat = vi.fn(async (args: { filePath: string }) => { + if (args.filePath.endsWith('untitled.md')) { + return { size: 0, isDirectory: false, mtime: 1 } + } + throw new Error('ENOENT: no such file') + }) const createFile = vi .fn() .mockRejectedValueOnce(new Error('EEXIST: file already exists')) @@ -16,7 +27,7 @@ describe('createUntitledMarkdownFile', () => { vi.stubGlobal('window', { api: { shell: { pathExists }, - fs: { createFile } + fs: { createFile, stat } } }) @@ -31,16 +42,18 @@ describe('createUntitledMarkdownFile', () => { expect(createFile).toHaveBeenNthCalledWith(1, { filePath: '/repo/untitled-2.md' }) expect(createFile).toHaveBeenNthCalledWith(2, { filePath: '/repo/untitled-3.md' }) + expect(pathExists).not.toHaveBeenCalled() }) it('throws a descriptive error when untitled names are exhausted', async () => { const pathExists = vi.fn(async () => true) + const stat = vi.fn().mockResolvedValue({ size: 0, isDirectory: false, mtime: 1 }) const createFile = vi.fn() vi.stubGlobal('window', { api: { shell: { pathExists }, - fs: { createFile } + fs: { createFile, stat } } }) @@ -49,16 +62,18 @@ describe('createUntitledMarkdownFile', () => { ) expect(createFile).not.toHaveBeenCalled() + expect(pathExists).not.toHaveBeenCalled() }) - it('passes connectionId to createFile and skips the local pathExists probe for SSH worktrees', async () => { + it('passes connectionId to stat and createFile for SSH worktrees', async () => { const pathExists = vi.fn(async () => false) + const stat = vi.fn().mockRejectedValue(new Error('ENOENT: no such file')) const createFile = vi.fn().mockResolvedValueOnce(undefined) vi.stubGlobal('window', { api: { shell: { pathExists }, - fs: { createFile } + fs: { createFile, stat } } }) @@ -66,13 +81,71 @@ describe('createUntitledMarkdownFile', () => { filePath: '/repo/untitled.md' }) - // Why: shell.pathExists is main-process local-only; probing it on SSH - // worktrees always reports "not found" or succeeds against the wrong - // filesystem, so the probe must be skipped when a connectionId is set. + // Why: shell.pathExists is main-process local-only; SSH worktrees must + // probe through the same filesystem API that receives the connectionId. expect(pathExists).not.toHaveBeenCalled() + expect(stat).toHaveBeenCalledWith({ + filePath: '/repo/untitled.md', + connectionId: 'conn-1' + }) expect(createFile).toHaveBeenCalledWith({ filePath: '/repo/untitled.md', connectionId: 'conn-1' }) }) + + it('creates untitled files through the selected runtime environment', async () => { + clearRuntimeCompatibilityCacheForTests() + const stat = vi.fn() + const createFile = vi.fn() + const runtimeEnvironmentCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'rpc-1', + ok: false, + error: { message: 'ENOENT: no such file' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'rpc-2', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + const runtimeEnvironmentTransportCall = vi.fn((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + + vi.stubGlobal('window', { + api: { + shell: { pathExists: vi.fn() }, + fs: { createFile, stat }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + + await expect( + createUntitledMarkdownFile('/remote/repo', 'wt-1', undefined, { + activeRuntimeEnvironmentId: 'env-1' + }) + ).resolves.toMatchObject({ + filePath: '/remote/repo/untitled.md', + relativePath: 'untitled.md' + }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'untitled.md' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.createFile', + params: { worktree: 'wt-1', relativePath: 'untitled.md' }, + timeoutMs: 15_000 + }) + expect(stat).not.toHaveBeenCalled() + expect(createFile).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/lib/create-untitled-markdown.ts b/src/renderer/src/lib/create-untitled-markdown.ts index ee3023dd712..3d41674f160 100644 --- a/src/renderer/src/lib/create-untitled-markdown.ts +++ b/src/renderer/src/lib/create-untitled-markdown.ts @@ -1,3 +1,5 @@ +import type { GlobalSettings } from '../../../shared/types' +import { createRuntimePath, runtimePathExists } from '../runtime/runtime-file-client' import { detectLanguage } from './language-detect' import { joinPath } from './path' @@ -11,7 +13,8 @@ import { joinPath } from './path' export async function createUntitledMarkdownFile( worktreePath: string, worktreeId: string, - connectionId?: string + connectionId?: string, + settings?: Pick | null ): Promise<{ filePath: string relativePath: string @@ -30,21 +33,19 @@ export async function createUntitledMarkdownFile( // nearly the same time. Retrying EEXIST keeps "New Markdown" advancing to // the next untitled-N name instead of surfacing a spurious error toast. // - // Why (SSH): window.api.shell.pathExists is a local-only main-process probe - // and cannot see files on a remote host. For SSH worktrees we skip the probe - // and rely solely on the EEXIST retry loop; otherwise every attempt reports - // "not found" locally, then fails in the main process when createFile tries - // to authorize the remote path against local allowed roots. + // Why: existence probing must go through the same runtime/SSH-aware file + // surface as creation; the shell probe only sees the client filesystem. for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { const fileName = attempt === 1 ? `${baseName}${ext}` : `${baseName}-${attempt}${ext}` const filePath = joinPath(worktreePath, fileName) + const context = { settings, worktreeId, worktreePath, connectionId } - if (!connectionId && (await window.api.shell.pathExists(filePath))) { + if (await runtimePathExists(context, filePath)) { continue } try { - await window.api.fs.createFile({ filePath, connectionId }) + await createRuntimePath(context, filePath, 'file') return { filePath, diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index 63f060b3ec6..eeb1f4a71a7 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -1,6 +1,7 @@ import type { AppState } from '@/store/types' import type { OrcaHooks } from '../../../shared/types' import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust' +import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' export type HookScriptKind = OrcaHookScriptKind @@ -31,13 +32,13 @@ export async function ensureHooksConfirmed( try { if (scriptKind === 'issueCommand') { // Local overrides are user-owned; only shared orca.yaml commands need repo trust. - const result = await window.api.hooks.readIssueCommand({ repoId }) + const result = await readRuntimeIssueCommand(state.settings, repoId) if (result.source !== 'shared') { return 'run' } scriptContent = (result.sharedContent ?? '').trim() } else { - const result = await window.api.hooks.check({ repoId }) + const result = await checkRuntimeHooks(state.settings, repoId) const yamlHooks = (result.hooks as OrcaHooks | null) ?? null scriptContent = (yamlHooks?.scripts?.[scriptKind] ?? '').trim() } diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index f62994e49ad..bdafd7e9981 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -6,7 +6,9 @@ const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() const storeState = { - settings: undefined as { openLinksInApp?: boolean } | undefined, + settings: undefined as + | { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null } + | undefined, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock } @@ -59,6 +61,16 @@ describe('openHttpLink', () => { expect(createBrowserTabMock).not.toHaveBeenCalled() }) + it('routes to the system browser when a remote runtime environment is active', () => { + storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-1' } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1' }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(setActiveWorktreeMock).not.toHaveBeenCalled() + }) + it('routes to the system browser when no worktree id is provided', () => { storeState.settings = { openLinksInApp: true } diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 541106718d3..5204be0daec 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -4,7 +4,7 @@ export type OpenHttpLinkOptions = { } type StoreAccessor = () => { - settings?: { openLinksInApp?: boolean } | null + settings?: { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null } | null setActiveWorktree: (worktreeId: string) => void createBrowserTab: (worktreeId: string, url: string, opts: { activate: boolean }) => unknown } @@ -28,8 +28,12 @@ export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void { export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void { const { worktreeId, forceSystemBrowser } = opts const state = storeAccessor?.() + const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim()) const routeToOrca = - !forceSystemBrowser && Boolean(worktreeId) && state?.settings?.openLinksInApp !== false + !remoteRuntimeActive && + !forceSystemBrowser && + Boolean(worktreeId) && + state?.settings?.openLinksInApp !== false if (routeToOrca && worktreeId && state) { // Why: http clicks from inside a worktree should not push a worktree-switch diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index aacf55857db..d8a13516bb2 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,6 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const mockSpawn = vi.fn() +const mockRuntimeEnvironmentCall = vi.fn() +const mockRuntimeEnvironmentTransportCall = vi.fn() +const mockRuntimeEnvironmentSubscribe = vi.fn() const mockCreateTab = vi.fn() const mockSetTabCustomTitle = vi.fn() const mockUpdateTabPtyId = vi.fn() @@ -11,7 +19,7 @@ const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const state = { - settings: { agentCmdOverrides: {} }, + settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null }, repos: [{ id: 'repo-1', connectionId: null }], allWorktrees: vi.fn(() => [ { id: 'wt-1', repoId: 'repo-1', path: '/repo/worktree', displayName: 'main' } @@ -47,15 +55,39 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ describe('launchAgentBackgroundSession', () => { beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() + mockRuntimeEnvironmentTransportCall.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => { + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) + ) + } + ) + state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } mockCreateTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' }) mockSpawn.mockResolvedValue({ id: 'pty-1' }) + mockRuntimeEnvironmentCall.mockResolvedValue({ + ok: true, + result: { terminal: { handle: 'terminal-1', worktreeId: 'wt-1', title: null } } + }) + mockRuntimeEnvironmentSubscribe.mockImplementation(async (_args, callbacks) => { + queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } })) + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + }) mockSubscribeToPtyData.mockReturnValue(vi.fn()) mockSubscribeToPtyExit.mockReturnValue(vi.fn()) vi.stubGlobal('window', { api: { pty: { spawn: mockSpawn + }, + runtime: { + call: vi.fn() + }, + runtimeEnvironments: { + call: mockRuntimeEnvironmentTransportCall, + subscribe: mockRuntimeEnvironmentSubscribe } } }) @@ -175,4 +207,43 @@ describe('launchAgentBackgroundSession', () => { }) ) }) + + it('creates background sessions on the active runtime environment', async () => { + state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + const result = await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).not.toHaveBeenCalled() + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.create', + params: expect.objectContaining({ + worktree: 'wt-1', + command: "claude 'run the automation'", + env: expect.objectContaining({ + ORCA_PANE_KEY: 'tab-1:1', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1' + }), + focus: false + }), + timeoutMs: 15_000 + }) + expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') + expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled() + expect(mockRuntimeEnvironmentSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex', + params: {} + }), + expect.any(Object) + ) + expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }) + }) }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index d0ae944bb18..710a0b5779d 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -14,7 +14,14 @@ import { subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' import { createAgentStatusOscProcessor } from '@/components/terminal-pane/agent-status-osc' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + getRemoteRuntimeTerminalHandle, + subscribeToRuntimeTerminalData, + toRemoteRuntimePtyId +} from '@/runtime/runtime-terminal-stream' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' export type LaunchAgentBackgroundSessionArgs = { agent: TuiAgent @@ -86,29 +93,49 @@ export async function launchAgentBackgroundSession( ORCA_TAB_ID: tab.id, ORCA_WORKTREE_ID: worktreeId } - let result: Awaited> + const runtimeTarget = getActiveRuntimeTarget(store.settings) + let ptyId: string try { - result = await window.api.pty.spawn({ - cols: 120, - rows: 40, - cwd: worktree.path, - command: startupPlan.launchCommand, - env: paneEnv, - connectionId: repo?.connectionId ?? null, - worktreeId, - tabId: tab.id, - leafId: 'pane:1', - telemetry: { - agent_kind: tuiAgentToAgentKind(agent), - launch_source: launchSource ?? 'unknown', - request_kind: 'new' - } - }) + if (runtimeTarget.kind === 'environment') { + // Why: runtime environments execute on the server; using local pty.spawn + // would silently run automation on the client for a remote workspace. + const created = await callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( + runtimeTarget, + 'terminal.create', + { + worktree: worktreeId, + command: startupPlan.launchCommand, + env: paneEnv, + title, + focus: false + }, + { timeoutMs: 15_000 } + ) + ptyId = toRemoteRuntimePtyId(created.terminal.handle, runtimeTarget.environmentId) + } else { + const result = await window.api.pty.spawn({ + cols: 120, + rows: 40, + cwd: worktree.path, + command: startupPlan.launchCommand, + env: paneEnv, + connectionId: repo?.connectionId ?? null, + worktreeId, + tabId: tab.id, + leafId: 'pane:1', + telemetry: { + agent_kind: tuiAgentToAgentKind(agent), + launch_source: launchSource ?? 'unknown', + request_kind: 'new' + } + }) + ptyId = result.id + } } catch (error) { store.closeTab(tab.id) throw error } - store.updateTabPtyId(tab.id, result.id) + store.updateTabPtyId(tab.id, ptyId) let exitHandled = false let unsubscribeExit = (): void => {} let unsubscribeData = (): void => {} @@ -122,19 +149,41 @@ export async function launchAgentBackgroundSession( useAppStore.getState().clearTabPtyId(tab.id, ptyId) onExit?.(ptyId, code) } - registerEagerPtyBuffer(result.id, handleExit) const processAgentStatus = createAgentStatusOscProcessor() - unsubscribeData = subscribeToPtyData(result.id, (data) => { + const handleData = (data: string): void => { const processed = processAgentStatus(data) for (const payload of processed.payloads) { useAppStore.getState().setAgentStatus(paneKey, payload, undefined) onAgentStatus?.(payload) } - }) - // Why: opening the workspace attaches a real terminal transport and disposes - // the eager exit handler. This sidecar keeps automation completion tracking - // alive regardless of whether the tab is hidden or mounted. - unsubscribeExit = subscribeToPtyExit(result.id, (code) => handleExit(result.id, code)) + } + if (runtimeTarget.kind === 'environment') { + unsubscribeData = await subscribeToRuntimeTerminalData( + store.settings, + ptyId, + `desktop:background:${tab.id}`, + handleData + ) + const terminal = getRemoteRuntimeTerminalHandle(ptyId) + if (!terminal) { + throw new Error('Runtime terminal id is invalid.') + } + void callRuntimeRpc<{ wait: { exitCode?: number | null } }>( + runtimeTarget, + 'terminal.wait', + { terminal, for: 'exit' }, + { timeoutMs: 24 * 60 * 60 * 1000 } + ) + .then((result) => handleExit(ptyId, result.wait.exitCode ?? 0)) + .catch(() => {}) + } else { + registerEagerPtyBuffer(ptyId, handleExit) + unsubscribeData = subscribeToPtyData(ptyId, handleData) + // Why: opening the workspace attaches a real terminal transport and disposes + // the eager exit handler. This sidecar keeps automation completion tracking + // alive regardless of whether the tab is hidden or mounted. + unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) + } if (pasteDraftAfterLaunch !== null) { void pasteDraftWhenAgentReady({ @@ -152,5 +201,5 @@ export async function launchAgentBackgroundSession( }) } - return { tabId: tab.id, ptyId: result.id, startupPlan } + return { tabId: tab.id, ptyId, startupPlan } } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 6a0ec8b3a5a..a297e508653 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -12,6 +12,7 @@ import { getWorkspaceSeedName } from '@/lib/new-workspace' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import type { OrcaHooks, @@ -91,7 +92,7 @@ async function resolveSetupDecision( ): Promise<{ kind: 'decided'; decision: SetupDecision } | { kind: 'needs-modal' }> { let yamlHooks: OrcaHooks | null = null try { - const result = await window.api.hooks.check({ repoId }) + const result = await checkRuntimeHooks(useAppStore.getState().settings, repoId) yamlHooks = (result.hooks as OrcaHooks | null) ?? null } catch { yamlHooks = null diff --git a/src/renderer/src/lib/local-path-open-guard.test.ts b/src/renderer/src/lib/local-path-open-guard.test.ts new file mode 100644 index 00000000000..011757af112 --- /dev/null +++ b/src/renderer/src/lib/local-path-open-guard.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { isLocalPathOpenBlocked } from './local-path-open-guard' + +describe('isLocalPathOpenBlocked', () => { + it('allows local paths without a runtime or SSH connection', () => { + expect(isLocalPathOpenBlocked({ activeRuntimeEnvironmentId: null })).toBe(false) + }) + + it('blocks paths while a runtime environment is active', () => { + expect(isLocalPathOpenBlocked({ activeRuntimeEnvironmentId: 'env-1' })).toBe(true) + }) + + it('blocks SSH-backed paths', () => { + expect( + isLocalPathOpenBlocked({ activeRuntimeEnvironmentId: null }, { connectionId: 'ssh-1' }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/local-path-open-guard.ts b/src/renderer/src/lib/local-path-open-guard.ts new file mode 100644 index 00000000000..3b34a7747f3 --- /dev/null +++ b/src/renderer/src/lib/local-path-open-guard.ts @@ -0,0 +1,15 @@ +import { toast } from 'sonner' +import type { GlobalSettings } from '../../../shared/types' + +export function isLocalPathOpenBlocked( + settings: Pick | null | undefined, + context?: { connectionId?: string | null } +): boolean { + return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || context?.connectionId?.trim()) +} + +export function showLocalPathOpenBlockedToast(): void { + // Why: local OS reveal/open actions receive client filesystem paths. Remote + // runtime and SSH paths belong to another machine, not this client. + toast.error('Opening remote paths in the local OS is not available.') +} diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 9327f240647..d15b28c05d6 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,5 +1,9 @@ import { useAppStore } from '@/store' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { + inspectRuntimeTerminalProcess, + sendRuntimePtyInput +} from '@/runtime/runtime-terminal-inspection' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { isShellProcess } from '@/lib/tui-agent-startup' import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types' @@ -242,7 +246,7 @@ export async function ensureAgentStartupInTerminal(args: { // session and submitted. Wait until the agent owns the PTY before writing. if (startup.followupPrompt) { await waitForAgentForeground(ptyId, startup.expectedProcess) - window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) + sendRuntimePtyInput(useAppStore.getState().settings, ptyId, `${startup.followupPrompt}\r`) } // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the @@ -267,7 +271,8 @@ async function waitForAgentForeground(ptyId: string, expectedProcess: string): P await new Promise((resolve) => window.setTimeout(resolve, 150)) } try { - const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? '' + const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId) + const foreground = process.foregroundProcess?.toLowerCase() ?? '' const owns = foreground === expectedProcess || foreground.startsWith(`${expectedProcess}.`) || @@ -276,8 +281,7 @@ async function waitForAgentForeground(ptyId: string, expectedProcess: string): P return } if (attempt >= 4 && !isShellProcess(foreground)) { - const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId) - if (hasChildProcesses) { + if (process.hasChildProcesses) { return } } diff --git a/src/renderer/src/lib/open-project-notes-tab.ts b/src/renderer/src/lib/open-project-notes-tab.ts index f072ecd22ea..cda85c7e695 100644 --- a/src/renderer/src/lib/open-project-notes-tab.ts +++ b/src/renderer/src/lib/open-project-notes-tab.ts @@ -1,5 +1,6 @@ import { useAppStore } from '@/store' import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events' +import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client' export function getProjectNotesEntityId(projectId: string, noteId?: string): string { if (noteId) { @@ -27,15 +28,16 @@ export async function openProjectNotesTab(worktreeId: string, noteId?: string): .find((candidate) => candidate.id === worktreeId) const repo = state.repos.find((candidate) => candidate.id === worktree?.repoId) const projectId = repo?.id ?? worktree?.repoId ?? null + const settings = state.settings if (noteId && projectId) { - await window.api.notes.link({ projectId, worktreeId, note: noteId, kind: 'active' }) + await linkRuntimeProjectNote(settings, { projectId, worktreeId, note: noteId, kind: 'active' }) } let label = 'Project Notes' if (noteId && projectId) { try { - const result = await window.api.notes.show({ projectId, worktreeId, note: noteId }) + const result = await showRuntimeProjectNote(settings, { projectId, worktreeId, note: noteId }) label = result.note.title } catch { label = 'Project Notes' diff --git a/src/renderer/src/lib/rename-file.ts b/src/renderer/src/lib/rename-file.ts index eef158e65e1..cf666e9d95a 100644 --- a/src/renderer/src/lib/rename-file.ts +++ b/src/renderer/src/lib/rename-file.ts @@ -5,6 +5,7 @@ import { basename, dirname, joinPath } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from '@/components/right-sidebar/fileExplorerUndoRedo' +import { renameRuntimePath } from '@/runtime/runtime-file-client' /** * Electron's ipcRenderer.invoke wraps errors as: @@ -124,20 +125,26 @@ export async function renameFileOnDisk(args: RenameFileArgs): Promise { file.filePath.startsWith(`${oldPath}\\`) ) await Promise.all(filesToQuiesce.map((file) => requestEditorSaveQuiesce({ fileId: file.id }))) + const fileContext = { + settings: state.settings, + worktreeId, + worktreePath, + connectionId + } try { - await window.api.fs.rename({ oldPath, newPath, connectionId }) + await renameRuntimePath(fileContext, oldPath, newPath) remapOpenTabsForRenamedPath(oldPath, newPath, worktreePath) commitFileExplorerOp({ undo: async () => { - await window.api.fs.rename({ oldPath: newPath, newPath: oldPath, connectionId }) + await renameRuntimePath(fileContext, newPath, oldPath) if (refreshDir) { await refreshDir(parentDir) } remapOpenTabsForRenamedPath(newPath, oldPath, worktreePath) }, redo: async () => { - await window.api.fs.rename({ oldPath, newPath, connectionId }) + await renameRuntimePath(fileContext, oldPath, newPath) if (refreshDir) { await refreshDir(parentDir) } diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 6c14526b093..09028c370b2 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -16,6 +16,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import type { Repo } from '../../../shared/types' +import type { GlobalSettings } from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' /** Lowercased `owner/repo` → Repo. Case folded because GitHub treats slugs * case-insensitively but displays the canonical casing; the lookup side @@ -23,17 +25,29 @@ import type { Repo } from '../../../shared/types' * canonical casing depending on when the project item was indexed. */ type SlugIndex = Map -/** Module-scope cache keyed by repo.id. A Repo that has already failed +/** Module-scope cache keyed by runtime scope + repo.id. A Repo that has already failed * resolution is not retried on re-mount; the value in the map is `null` * to record the negative result so we don't keep poking `git remote` for * repos that will never match. */ const slugByRepoId = new Map() +function slugCacheKey( + repoId: string, + settings: Pick | null | undefined +): string { + const target = getActiveRuntimeTarget(settings) + return `${target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'}:${repoId}` +} + /** Drop a repo's cached slug result. Call when a repo is removed or its * remote URL is known to have changed (e.g. after `git remote set-url`), * so the next index build re-resolves rather than serving a stale entry. */ export function clearRepoSlugCacheEntry(repoId: string): void { - slugByRepoId.delete(repoId) + for (const key of slugByRepoId.keys()) { + if (key.endsWith(`:${repoId}`)) { + slugByRepoId.delete(key) + } + } } /** Clear the entire slug cache. Useful for tests or full repo-list resets. */ @@ -41,41 +55,57 @@ export function clearRepoSlugCache(): void { slugByRepoId.clear() } -async function resolveRepoSlug(repo: Repo): Promise { - if (slugByRepoId.has(repo.id)) { - return slugByRepoId.get(repo.id) ?? null +async function resolveRepoSlug( + repo: Repo, + settings: Pick | null | undefined +): Promise { + const cacheKey = slugCacheKey(repo.id, settings) + if (slugByRepoId.has(cacheKey)) { + return slugByRepoId.get(cacheKey) ?? null } try { - const result = await window.api.gh.repoSlug({ repoPath: repo.path }) + const target = getActiveRuntimeTarget(settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ owner: string; repo: string } | null>( + target, + 'github.repoSlug', + { repo: repo.id }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.repoSlug({ repoPath: repo.path }) if (!result) { - slugByRepoId.set(repo.id, null) + slugByRepoId.set(cacheKey, null) return null } const slug = `${result.owner}/${result.repo}`.toLowerCase() - slugByRepoId.set(repo.id, slug) + slugByRepoId.set(cacheKey, slug) return slug } catch { // Why: treat any IPC failure as "not resolvable" rather than propagating — // design doc §Row actions: "If gh:repoSlug fails for a repo, exclude it". - slugByRepoId.set(repo.id, null) + slugByRepoId.set(cacheKey, null) return null } } -async function buildIndex(repos: Repo[]): Promise { +async function buildIndex( + repos: Repo[], + settings: Pick | null | undefined +): Promise { // Why: evict cached entries for repos that no longer exist in state so // the cache cannot grow unbounded across long sessions where users add // and remove repos. Without this, every removed repo's id (and its // negative-cached null) lingers forever. - const liveIds = new Set(repos.map((r) => r.id)) - for (const id of slugByRepoId.keys()) { - if (!liveIds.has(id)) { - slugByRepoId.delete(id) + const liveKeys = new Set(repos.map((r) => slugCacheKey(r.id, settings))) + for (const key of slugByRepoId.keys()) { + if (!liveKeys.has(key)) { + slugByRepoId.delete(key) } } const next: SlugIndex = new Map() const results = await Promise.all( - repos.map(async (r) => ({ repo: r, slug: await resolveRepoSlug(r) })) + repos.map(async (r) => ({ repo: r, slug: await resolveRepoSlug(r, settings) })) ) for (const { repo, slug } of results) { if (slug) { @@ -90,6 +120,7 @@ async function buildIndex(repos: Repo[]): Promise { * treat it as referentially equal inside a single render cycle. */ export function useRepoSlugIndex(): (slug: string | null | undefined) => Repo | null { const repos = useAppStore((s) => s.repos) + const settings = useAppStore((s) => s.settings) const [index, setIndex] = useState(() => new Map()) // Why: track the current repos snapshot so the effect can ignore stale // resolutions when repos change mid-flight. @@ -97,13 +128,13 @@ export function useRepoSlugIndex(): (slug: string | null | undefined) => Repo | useEffect(() => { const gen = ++generationRef.current - void buildIndex(repos).then((next) => { + void buildIndex(repos, settings).then((next) => { if (gen !== generationRef.current) { return } setIndex(next) }) - }, [repos]) + }, [repos, settings]) return useMemo( () => diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index bc563959b0b..8b31ebaccce 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -111,7 +111,8 @@ export function buildEditorSessionData( relativePath: f.relativePath, worktreeId: f.worktreeId, language: f.language, - isPreview: f.isPreview || undefined + isPreview: f.isPreview || undefined, + runtimeEnvironmentId: f.runtimeEnvironmentId }) const ids = editFileIdsByWorktree[f.worktreeId] ?? (editFileIdsByWorktree[f.worktreeId] = new Set()) diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index 58d11e303c5..7158c64ecc0 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -9,6 +9,8 @@ import { flushPendingEditorChange } from '@/components/editor/editor-pending-flu import { getConnectionId } from '@/lib/connection-context' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' +import { readRuntimeFileContent } from './runtime-file-client' +import { settingsForRuntimeOwner } from './runtime-rpc-client' import { hashMarkdownContent, MOBILE_MARKDOWN_EDIT_MAX_BYTES, @@ -238,8 +240,12 @@ async function readCurrentContent( async function readFileContent(file: OpenFile): Promise { const connectionId = getConnectionId(file.worktreeId) ?? undefined - const result = (await window.api.fs.readFile({ + const state = useAppStore.getState() + const result = (await readRuntimeFileContent({ + settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, connectionId })) as FileContent if (result.isBinary) { diff --git a/src/renderer/src/runtime/remote-file-client.ts b/src/renderer/src/runtime/remote-file-client.ts new file mode 100644 index 00000000000..9958ac957d7 --- /dev/null +++ b/src/renderer/src/runtime/remote-file-client.ts @@ -0,0 +1,22 @@ +import type { GlobalSettings } from '../../../shared/types' +import { readRuntimeFileContent, type RuntimeReadableFileContent } from './runtime-file-client' + +export type RemoteReadableFile = { + worktreeId: string + relativePath: string + filePath?: string +} + +export type RemoteFileContent = RuntimeReadableFileContent + +export async function readFileFromActiveRuntime( + settings: Pick | null | undefined, + file: RemoteReadableFile +): Promise { + return readRuntimeFileContent({ + settings, + filePath: file.filePath ?? file.relativePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId + }) +} diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts new file mode 100644 index 00000000000..2bf32cccbd9 --- /dev/null +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -0,0 +1,379 @@ +/* eslint-disable max-lines -- Why: the remote terminal multiplexer owns one bridged subscription, stream lifecycle, binary frame parsing, and remote lock events as a single transport contract. */ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { unwrapRuntimeRpcResult } from './runtime-rpc-client' + +type RuntimeEnvironmentSubscriptionHandle = { + unsubscribe: () => void + sendBinary: (bytes: Uint8Array) => void +} + +type TerminalMultiplexEvent = + | { type: 'ready' } + | { type: 'subscribed'; streamId: number } + | { type: 'end'; streamId: number } + | { type: 'error'; streamId: number; message?: string } + | { + type: 'fit-override-changed' + streamId: number + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + } + | { + type: 'driver-changed' + streamId: number + driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } + } + | { type: string; streamId?: number; [key: string]: unknown } + +export type RemoteRuntimeMultiplexedTerminalCallbacks = { + onData: (data: string) => void + onSnapshot: (data: string) => void + onSubscribed?: () => void + onEnd?: () => void + onError?: (message: string) => void + onFitOverrideChanged?: (event: { + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + onDriverChanged?: ( + driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } + ) => void + onTransportClose?: () => void +} + +export type RemoteRuntimeMultiplexedTerminal = { + streamId: number + sendInput: (text: string) => boolean + resize: (cols: number, rows: number) => boolean + close: () => void +} + +type RemoteRuntimeMultiplexedTerminalState = { + streamId: number + terminal: string + callbacks: RemoteRuntimeMultiplexedTerminalCallbacks + snapshotChunks: Uint8Array[] +} + +const CONTROL_STREAM_ID = 0 + +class RemoteRuntimeTerminalMultiplexer { + private readonly streams = new Map() + private subscription: RuntimeEnvironmentSubscriptionHandle | null = null + private connectPromise: Promise | null = null + private readyResolver: (() => void) | null = null + private readyRejecter: ((error: Error) => void) | null = null + private ready = false + private nextStreamId = 1 + + constructor(private readonly environmentId: string) {} + + async subscribeTerminal(args: { + terminal: string + client: { id: string; type: 'desktop' | 'mobile' } + viewport?: { cols: number; rows: number } + callbacks: RemoteRuntimeMultiplexedTerminalCallbacks + }): Promise { + const streamId = this.allocateStreamId() + const state: RemoteRuntimeMultiplexedTerminalState = { + streamId, + terminal: args.terminal, + callbacks: args.callbacks, + snapshotChunks: [] + } + this.streams.set(streamId, state) + + const stream: RemoteRuntimeMultiplexedTerminal = { + streamId, + sendInput: (text) => + this.sendFrame(streamId, TerminalStreamOpcode.Input, encodeTerminalStreamText(text)), + resize: (cols, rows) => + this.sendFrame( + streamId, + TerminalStreamOpcode.Resize, + encodeTerminalStreamJson({ cols, rows }) + ), + close: () => { + if (this.streams.get(streamId) === state) { + this.sendFrame(streamId, TerminalStreamOpcode.Unsubscribe) + this.streams.delete(streamId) + this.closeIfIdle() + } + } + } + + try { + await this.ensureConnected() + if (this.streams.get(streamId) !== state) { + return stream + } + const sent = this.sendFrame( + CONTROL_STREAM_ID, + TerminalStreamOpcode.Subscribe, + encodeTerminalStreamJson({ + streamId, + terminal: args.terminal, + client: args.client, + viewport: args.viewport + }) + ) + if (!sent) { + throw new Error('Remote terminal stream is not connected.') + } + } catch (error) { + const terminalError = error instanceof Error ? error : new Error(String(error)) + if (this.streams.get(streamId) === state) { + this.streams.delete(streamId) + this.closeIfIdle() + } + throw terminalError + } + + return stream + } + + private allocateStreamId(): number { + const start = this.nextStreamId + do { + const candidate = this.nextStreamId + this.nextStreamId = this.nextStreamId >= 0x7fffffff ? 1 : this.nextStreamId + 1 + if (!this.streams.has(candidate)) { + return candidate + } + } while (this.nextStreamId !== start) + throw new Error('No remote terminal stream ids available.') + } + + private ensureConnected(): Promise { + if (this.ready && this.subscription) { + return Promise.resolve() + } + if (this.connectPromise) { + return this.connectPromise + } + this.connectPromise = new Promise((resolve, reject) => { + this.readyResolver = resolve + this.readyRejecter = reject + void window.api.runtimeEnvironments + .subscribe( + { + selector: this.environmentId, + method: 'terminal.multiplex', + params: {}, + timeoutMs: 15_000 + }, + { + onResponse: (response) => this.handleResponse(response), + onBinary: (bytes) => this.handleBinary(bytes), + onError: (error) => this.failConnection(new Error(error.message)), + onClose: () => this.handleClose('Remote Orca runtime closed the connection.') + } + ) + .then((subscription) => { + this.subscription = subscription + this.resolveReadyIfConnected() + }) + .catch((error) => { + this.connectPromise = null + this.readyResolver = null + this.readyRejecter = null + reject(error instanceof Error ? error : new Error(String(error))) + }) + }) + return this.connectPromise + } + + private handleResponse(response: RuntimeRpcResponse): void { + let event: TerminalMultiplexEvent + try { + event = unwrapRuntimeRpcResult(response) as TerminalMultiplexEvent + } catch (error) { + this.failConnection(error instanceof Error ? error : new Error(String(error))) + return + } + + if (event.type === 'ready') { + this.ready = true + this.resolveReadyIfConnected() + return + } + + if (!('streamId' in event) || typeof event.streamId !== 'number') { + return + } + const stream = this.streams.get(event.streamId) + if (!stream) { + return + } + if (event.type === 'end') { + this.streams.delete(event.streamId) + stream.callbacks.onEnd?.() + this.closeIfIdle() + } else if (event.type === 'error') { + stream.callbacks.onError?.( + typeof event.message === 'string' ? event.message : 'Remote terminal stream failed.' + ) + } else if (event.type === 'fit-override-changed') { + if ( + (event.mode !== 'mobile-fit' && event.mode !== 'desktop-fit') || + typeof event.cols !== 'number' || + typeof event.rows !== 'number' + ) { + return + } + stream.callbacks.onFitOverrideChanged?.({ + mode: event.mode, + cols: event.cols, + rows: event.rows + }) + } else if (event.type === 'driver-changed') { + if (!isTerminalDriverState(event.driver)) { + return + } + stream.callbacks.onDriverChanged?.(event.driver) + } + } + + private handleBinary(bytes: Uint8Array): void { + const frame = decodeTerminalStreamFrame(bytes) + if (!frame) { + return + } + const stream = this.streams.get(frame.streamId) + if (!stream) { + return + } + if (frame.opcode === TerminalStreamOpcode.Output) { + stream.callbacks.onData(decodeTerminalStreamText(frame.payload)) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { + stream.snapshotChunks = [] + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) { + stream.snapshotChunks.push(frame.payload) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) { + stream.callbacks.onSnapshot(decodeTerminalStreamText(concatBytes(stream.snapshotChunks))) + stream.snapshotChunks = [] + stream.callbacks.onSubscribed?.() + return + } + if (frame.opcode === TerminalStreamOpcode.Error) { + stream.callbacks.onError?.(decodeTerminalStreamText(frame.payload)) + } + } + + private sendFrame( + streamId: number, + opcode: TerminalStreamOpcode, + payload: Uint8Array = new Uint8Array() + ): boolean { + if (!this.ready || !this.subscription) { + return false + } + this.subscription.sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: 0, payload })) + return true + } + + private resolveReadyIfConnected(): void { + if (!this.ready || !this.subscription) { + return + } + this.readyResolver?.() + this.readyResolver = null + this.readyRejecter = null + } + + private failConnection(error: Error): void { + this.readyRejecter?.(error) + this.readyResolver = null + this.readyRejecter = null + for (const stream of this.streams.values()) { + stream.callbacks.onError?.(error.message) + } + this.subscription?.unsubscribe() + this.handleClose() + } + + private handleClose(message?: string): void { + const streams = Array.from(this.streams.values()) + this.ready = false + this.connectPromise = null + this.readyRejecter?.(new Error(message ?? 'Remote runtime connection closed.')) + this.readyResolver = null + this.readyRejecter = null + this.subscription = null + this.streams.clear() + for (const stream of streams) { + stream.callbacks.onTransportClose?.() + if (message) { + stream.callbacks.onError?.(message) + } + } + } + + private closeIfIdle(): void { + if (this.streams.size > 0) { + return + } + this.subscription?.unsubscribe() + this.subscription = null + this.connectPromise = null + this.ready = false + } +} + +const multiplexers = new Map() + +export function getRemoteRuntimeTerminalMultiplexer( + environmentId: string +): RemoteRuntimeTerminalMultiplexer { + let multiplexer = multiplexers.get(environmentId) + if (!multiplexer) { + multiplexer = new RemoteRuntimeTerminalMultiplexer(environmentId) + multiplexers.set(environmentId, multiplexer) + } + return multiplexer +} + +export function resetRemoteRuntimeTerminalMultiplexersForTests(): void { + multiplexers.clear() +} + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.byteLength + } + return out +} + +function isTerminalDriverState( + value: unknown +): value is { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } { + if (!value || typeof value !== 'object' || !('kind' in value)) { + return false + } + const driver = value as { kind?: unknown; clientId?: unknown } + return ( + driver.kind === 'idle' || + driver.kind === 'desktop' || + (driver.kind === 'mobile' && typeof driver.clientId === 'string') + ) +} diff --git a/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts new file mode 100644 index 00000000000..eb1bff0c680 --- /dev/null +++ b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts @@ -0,0 +1,37 @@ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' + +export type RuntimeEnvironmentCallRequest = { + method: string +} + +export function createCompatibleRuntimeStatusResponse( + runtimeId = 'remote-runtime' +): RuntimeRpcResponse { + return { + id: 'status', + ok: true, + result: { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId } + } +} + +export function createCompatibleRuntimeStatusResponseIfNeeded( + args: RuntimeEnvironmentCallRequest, + runtimeId?: string +): RuntimeRpcResponse | null { + return args.method === 'status.get' ? createCompatibleRuntimeStatusResponse(runtimeId) : null +} diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts new file mode 100644 index 00000000000..582d4af028b --- /dev/null +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -0,0 +1,1186 @@ +/* eslint-disable max-lines -- Why: the runtime file client mirrors the file +preload API plus remote fallbacks; keeping route coverage together makes local +versus environment behavior easy to audit. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + copyRuntimePath, + createRuntimePath, + deleteRuntimePath, + getRuntimeFileReadScope, + importExternalPathsToRuntime, + listRuntimeFiles, + listRuntimeMarkdownDocuments, + readRuntimeDirectory, + readRuntimeFileContent, + readRuntimeFilePreview, + renameRuntimePath, + searchRuntimeFiles, + statRuntimePath, + subscribeRuntimeFileChanges, + type RuntimeReadableFileContent +} from './runtime-file-client' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' + +const fsReadFile = vi.fn() +const fsOnChanged = vi.fn() +const fsCopy = vi.fn() +const fsCreateDir = vi.fn() +const fsCreateFile = vi.fn() +const fsRename = vi.fn() +const fsDeletePath = vi.fn() +const fsStat = vi.fn() +const fsImportExternalPaths = vi.fn() +const fsStageExternalPathsForRuntimeUpload = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const runtimeEnvironmentSubscribe = vi.fn() +const runtimeCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + fsReadFile.mockReset() + fsOnChanged.mockReset() + fsCopy.mockReset() + fsCreateDir.mockReset() + fsCreateFile.mockReset() + fsRename.mockReset() + fsDeletePath.mockReset() + fsStat.mockReset() + fsImportExternalPaths.mockReset() + fsStageExternalPathsForRuntimeUpload.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentSubscribe.mockReset() + runtimeCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: { method: string }) => { + if (args.method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + fs: { + readFile: fsReadFile, + onFsChanged: fsOnChanged, + copy: fsCopy, + createDir: fsCreateDir, + createFile: fsCreateFile, + rename: fsRename, + deletePath: fsDeletePath, + stat: fsStat, + importExternalPaths: fsImportExternalPaths, + stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload + }, + runtime: { call: runtimeCall }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + subscribe: runtimeEnvironmentSubscribe + } + } + }) +}) + +describe('runtime file client', () => { + it('uses local filesystem reads when no remote runtime is active', async () => { + const localResult: RuntimeReadableFileContent = { content: 'hello', isBinary: false } + fsReadFile.mockResolvedValue(localResult) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: null }, + filePath: '/repo/readme.md', + relativePath: 'readme.md', + worktreeId: 'wt-1', + connectionId: 'ssh-1' + }) + ).resolves.toBe(localResult) + + expect(fsReadFile).toHaveBeenCalledWith({ filePath: '/repo/readme.md', connectionId: 'ssh-1' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes worktree-relative text reads through the selected runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-1', + relativePath: 'src/index.ts', + content: 'export {}\n', + truncated: false, + byteLength: 10 + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/remote/repo/src/index.ts', + relativePath: 'src/index.ts', + worktreeId: 'wt-1' + }) + ).resolves.toEqual({ content: 'export {}\n', isBinary: false }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.read', + params: { worktree: 'wt-1', relativePath: 'src/index.ts' }, + timeoutMs: 15_000 + }) + expect(fsReadFile).not.toHaveBeenCalled() + }) + + it('keeps external absolute-path files on the local filesystem path', async () => { + const localResult: RuntimeReadableFileContent = { content: 'scratch', isBinary: false } + fsReadFile.mockResolvedValue(localResult) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/Users/me/scratch.md', + relativePath: '/Users/me/scratch.md' + }) + ).resolves.toBe(localResult) + + expect(fsReadFile).toHaveBeenCalledWith({ + filePath: '/Users/me/scratch.md', + connectionId: undefined + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('rejects remote-owned text reads that are not worktree-relative', async () => { + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/tmp/scratch.md', + relativePath: '/tmp/scratch.md', + worktreeId: 'wt-1' + }) + ).rejects.toThrow('Remote file is outside the owning runtime worktree') + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/remote/repo/unknown.md', + worktreeId: 'wt-1' + }) + ).rejects.toThrow('Remote file is outside the owning runtime worktree') + expect(fsReadFile).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('rejects truncated remote reads instead of returning partial editable content', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-1', + relativePath: 'large.log', + content: 'partial', + truncated: true, + byteLength: 524_288 + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/remote/repo/large.log', + relativePath: 'large.log', + worktreeId: 'wt-1' + }) + ).rejects.toThrow('Remote file is too large to open in the editor') + }) + + it('uses the active runtime id as the dedupe scope', () => { + expect(getRuntimeFileReadScope({ activeRuntimeEnvironmentId: 'env-1' }, 'ssh-1')).toBe( + 'runtime:env-1' + ) + expect(getRuntimeFileReadScope({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe('ssh-1') + }) + + it('routes directory reads through the selected runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: [{ name: 'src', isDirectory: true }], + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + readRuntimeDirectory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo/src' + ) + ).resolves.toEqual([{ name: 'src', isDirectory: true }]) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readDir', + params: { worktree: 'wt-1', relativePath: 'src' }, + timeoutMs: 15_000 + }) + }) + + it('routes Windows drive paths case-insensitively through the selected runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: [], + _meta: { runtimeId: 'remote-runtime' } + }) + + await readRuntimeDirectory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: 'C:\\Repo' + }, + 'c:\\repo\\Src' + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readDir', + params: { worktree: 'wt-1', relativePath: 'Src' }, + timeoutMs: 15_000 + }) + }) + + it('routes forward-slash UNC paths through the selected runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: [], + _meta: { runtimeId: 'remote-runtime' } + }) + + await readRuntimeDirectory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '//Server/Share/Repo' + }, + '//server/share/repo/src' + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readDir', + params: { worktree: 'wt-1', relativePath: 'src' }, + timeoutMs: 15_000 + }) + }) + + it('routes preview reads through the selected runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { content: 'base64', isBinary: true, isImage: true, mimeType: 'image/png' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + readRuntimeFilePreview( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo/images/logo.png' + ) + ).resolves.toEqual({ + content: 'base64', + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readPreview', + params: { worktree: 'wt-1', relativePath: 'images/logo.png' }, + timeoutMs: 15_000 + }) + }) + + it('does not fall back to client-local preview reads for remote-owned files outside the worktree', async () => { + await expect( + readRuntimeFilePreview( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/tmp/logo.png' + ) + ).rejects.toThrow('outside the owning runtime worktree') + + expect(fsReadFile).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes root directory reads with an empty relative path', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: [], + _meta: { runtimeId: 'remote-runtime' } + }) + + await readRuntimeDirectory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo' + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readDir', + params: { worktree: 'wt-1', relativePath: '' }, + timeoutMs: 15_000 + }) + }) + + it('does not fall back to client-local directory reads for remote-owned paths outside the worktree', async () => { + await expect( + readRuntimeDirectory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo', + connectionId: 'ssh-1' + }, + '/tmp' + ) + ).rejects.toThrow('outside the owning runtime worktree') + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes create, rename, copy, and delete mutations through the selected runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + } + + await createRuntimePath(context, '/remote/repo/src/new.ts', 'file') + await renameRuntimePath(context, '/remote/repo/src/new.ts', '/remote/repo/src/renamed.ts') + await copyRuntimePath( + context, + '/remote/repo/src/renamed.ts', + '/remote/repo/src/renamed copy.ts' + ) + await deleteRuntimePath(context, '/remote/repo/src/renamed.ts', false) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'files.createFile', + params: { worktree: 'wt-1', relativePath: 'src/new.ts' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.rename', + params: { + worktree: 'wt-1', + oldRelativePath: 'src/new.ts', + newRelativePath: 'src/renamed.ts' + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'files.copy', + params: { + worktree: 'wt-1', + sourceRelativePath: 'src/renamed.ts', + destinationRelativePath: 'src/renamed copy.ts' + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'src/renamed.ts', recursive: false }, + timeoutMs: 15_000 + }) + }) + + it('does not fall back to client-local mutations for remote-owned paths outside the worktree', async () => { + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + } + + await expect(createRuntimePath(context, '/tmp/new.ts', 'file')).rejects.toThrow( + 'outside the owning runtime worktree' + ) + await expect( + renameRuntimePath(context, '/remote/repo/src/new.ts', '/tmp/renamed.ts') + ).rejects.toThrow('outside the owning runtime worktree') + await expect( + copyRuntimePath(context, '/remote/repo/src/new.ts', '/tmp/copied.ts') + ).rejects.toThrow('outside the owning runtime worktree') + await expect(deleteRuntimePath(context, '/tmp/new.ts')).rejects.toThrow( + 'outside the owning runtime worktree' + ) + + expect(fsCreateFile).not.toHaveBeenCalled() + expect(fsRename).not.toHaveBeenCalled() + expect(fsCopy).not.toHaveBeenCalled() + expect(fsDeletePath).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('does not fall back to client-local mutations when a remote Windows path escapes the worktree', async () => { + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: 'C:\\repo' + } + + await expect(createRuntimePath(context, 'D:\\repo\\new.ts', 'file')).rejects.toThrow( + 'outside the owning runtime worktree' + ) + await expect( + createRuntimePath(context, '\\\\server\\share\\repo\\new.ts', 'file') + ).rejects.toThrow('outside the owning runtime worktree') + + expect(fsCreateFile).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('keeps copy operations on local filesystem IPC when no runtime is active', async () => { + await copyRuntimePath( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + '/repo/a.md', + '/repo/a copy.md' + ) + + expect(fsCopy).toHaveBeenCalledWith({ + sourcePath: '/repo/a.md', + destinationPath: '/repo/a copy.md', + connectionId: undefined + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('preserves the SSH connection for copy operations when no runtime is active', async () => { + await copyRuntimePath( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }, + '/repo/a.md', + '/repo/a copy.md' + ) + + expect(fsCopy).toHaveBeenCalledWith({ + sourcePath: '/repo/a.md', + destinationPath: '/repo/a copy.md', + connectionId: 'ssh-1' + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('uploads staged local drops into the selected runtime environment', async () => { + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/assets', + status: 'staged', + name: 'assets', + kind: 'directory', + entries: [ + { relativePath: '', kind: 'directory' }, + { relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' } + ] + } + ] + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'stat-destination-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'create-destination-dir', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'stat-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'create-dir', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'write-file', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'commit-upload', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'delete-temp', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/assets'], + '/remote/repo/uploads' + ) + ).resolves.toEqual({ + results: [ + { + sourcePath: '/Users/me/assets', + status: 'imported', + destPath: '/remote/repo/uploads/assets', + kind: 'directory', + renamed: false + } + ] + }) + + expect(fsStageExternalPathsForRuntimeUpload).toHaveBeenCalledWith({ + sourcePaths: ['/Users/me/assets'] + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'uploads' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.createDir', + params: { worktree: 'wt-1', relativePath: 'uploads' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'uploads/assets' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { + selector: 'env-1', + method: 'files.createDirNoClobber', + params: { worktree: 'wt-1', relativePath: 'uploads/assets' }, + timeoutMs: 15_000 + }) + const smallWriteCall = runtimeEnvironmentCall.mock.calls[4]?.[0] as { + params: { relativePath: string } + } + expect(smallWriteCall.params.relativePath).toMatch( + /^uploads\/assets\/\.logo\.png\.orca-upload-/ + ) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { + selector: 'env-1', + method: 'files.writeBase64', + params: { + worktree: 'wt-1', + relativePath: smallWriteCall.params.relativePath, + contentBase64: 'cG5n' + }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { + selector: 'env-1', + method: 'files.commitUpload', + params: { + worktree: 'wt-1', + tempRelativePath: smallWriteCall.params.relativePath, + finalRelativePath: 'uploads/assets/logo.png' + }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { + selector: 'env-1', + method: 'files.delete', + params: { + worktree: 'wt-1', + relativePath: smallWriteCall.params.relativePath, + recursive: false + }, + timeoutMs: 15_000 + }) + expect(fsImportExternalPaths).not.toHaveBeenCalled() + }) + + it('chunks large staged runtime uploads below the WebSocket frame budget', async () => { + const firstChunk = 'A'.repeat(512 * 1024) + const secondChunk = 'BBBBBBBB' + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/large.bin', + status: 'staged', + name: 'large.bin', + kind: 'file', + entries: [ + { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } + ] + } + ] + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'stat-destination-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'create-destination-dir', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'stat-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'write-chunk-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'write-chunk-2', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'commit-upload', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'delete-temp', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/large.bin'], + '/remote/repo/uploads' + ) + ).resolves.toEqual({ + results: [ + { + sourcePath: '/Users/me/large.bin', + status: 'imported', + destPath: '/remote/repo/uploads/large.bin', + kind: 'file', + renamed: false + } + ] + }) + + const chunkWriteCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as { + params: { relativePath: string } + } + expect(chunkWriteCall.params.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { + selector: 'env-1', + method: 'files.writeBase64Chunk', + params: { + worktree: 'wt-1', + relativePath: chunkWriteCall.params.relativePath, + contentBase64: firstChunk, + append: false + }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { + selector: 'env-1', + method: 'files.writeBase64Chunk', + params: { + worktree: 'wt-1', + relativePath: chunkWriteCall.params.relativePath, + contentBase64: secondChunk, + append: true + }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { + selector: 'env-1', + method: 'files.commitUpload', + params: { + worktree: 'wt-1', + tempRelativePath: chunkWriteCall.params.relativePath, + finalRelativePath: 'uploads/large.bin' + }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { + selector: 'env-1', + method: 'files.delete', + params: { + worktree: 'wt-1', + relativePath: chunkWriteCall.params.relativePath, + recursive: false + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.writeBase64' }) + ) + }) + + it('cleans up staged runtime upload temp files when a later chunk fails', async () => { + const firstChunk = 'A'.repeat(512 * 1024) + const secondChunk = 'BBBBBBBB' + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/large.bin', + status: 'staged', + name: 'large.bin', + kind: 'file', + entries: [ + { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } + ] + } + ] + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'stat-destination-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'create-destination-dir', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'stat-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'write-chunk-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'write-chunk-2', + ok: false, + error: { code: 'write_failed', message: 'disk full' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'delete-temp', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/large.bin'], + '/remote/repo/uploads' + ) + ).resolves.toMatchObject({ + results: [{ status: 'failed', reason: 'disk full' }] + }) + + const chunkCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as + | { params: { relativePath: string } } + | undefined + if (!chunkCall) { + throw new Error('missing first chunk call') + } + const tempRelativePath = chunkCall.params.relativePath + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.commitUpload' }) + ) + expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: tempRelativePath, recursive: false }, + timeoutMs: 15_000 + }) + }) + + it('keeps local external imports on filesystem IPC when no runtime is active', async () => { + fsImportExternalPaths.mockResolvedValue({ + results: [ + { + sourcePath: '/Users/me/readme.md', + status: 'imported', + destPath: '/repo/readme.md', + kind: 'file', + renamed: false + } + ] + }) + + await importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }, + ['/Users/me/readme.md'], + '/repo', + { ensureDestinationDir: true } + ) + + expect(fsImportExternalPaths).toHaveBeenCalledWith({ + sourcePaths: ['/Users/me/readme.md'], + destDir: '/repo', + connectionId: 'ssh-1', + ensureDir: true + }) + expect(fsStageExternalPathsForRuntimeUpload).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes text search through the selected runtime without sending client root paths', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { files: [], totalMatches: 0, truncated: false }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + searchRuntimeFiles( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { + query: 'needle', + rootPath: '/remote/repo', + caseSensitive: true, + maxResults: 50 + } + ) + ).resolves.toEqual({ files: [], totalMatches: 0, truncated: false }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.search', + params: { worktree: 'wt-1', query: 'needle', caseSensitive: true, maxResults: 50 }, + timeoutMs: 15_000 + }) + }) + + it('routes quick-open file listing through the selected runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: ['src/index.ts'], + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + listRuntimeFiles( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { + rootPath: '/remote/repo', + excludePaths: ['/remote/repo-other'] + } + ) + ).resolves.toEqual(['src/index.ts']) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.listAll', + params: { worktree: 'wt-1', excludePaths: ['/remote/repo-other'] }, + timeoutMs: 15_000 + }) + }) + + it('routes markdown document listing and stat through the selected runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: [{ relativePath: 'readme.md' }], + _meta: { runtimeId: 'remote-runtime' } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + } + + await listRuntimeMarkdownDocuments(context, '/remote/repo') + await statRuntimePath(context, '/remote/repo/readme.md') + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'files.listMarkdownDocuments', + params: { worktree: 'wt-1' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'readme.md' }, + timeoutMs: 15_000 + }) + }) + + it('does not fall back to client-local stat for remote-owned paths outside the worktree', async () => { + await expect( + statRuntimePath( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/tmp/readme.md' + ) + ).rejects.toThrow('outside the owning runtime worktree') + + await expect( + statRuntimePath( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: 'C:\\repo' + }, + '\\\\server\\share\\repo\\readme.md' + ) + ).rejects.toThrow('outside the owning runtime worktree') + + expect(fsStat).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('uses the local fs changed stream when no runtime environment is active', async () => { + const unsubscribe = vi.fn() + const onPayload = vi.fn() + fsOnChanged.mockReturnValue(unsubscribe) + + await expect( + subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + onPayload + ) + ).resolves.toBe(unsubscribe) + + expect(fsOnChanged).toHaveBeenCalledWith(onPayload) + expect(runtimeEnvironmentSubscribe).not.toHaveBeenCalled() + }) + + it('maps runtime file watch events back to fs changed payloads', async () => { + const onPayload = vi.fn() + const unsubscribe = vi.fn() + let onResponse: ((response: unknown) => void) | undefined + runtimeEnvironmentSubscribe.mockImplementation((_args, callbacks) => { + onResponse = callbacks.onResponse + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + + const stop = await subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + onPayload + ) + + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledWith( + { + selector: 'env-1', + method: 'files.watch', + params: { worktree: 'wt-1' }, + timeoutMs: 15_000 + }, + expect.any(Object) + ) + + onResponse?.({ + id: 'rpc-1', + ok: true, + result: { + type: 'changed', + worktree: 'wt-1', + events: [{ kind: 'update', absolutePath: '/remote/repo/readme.md' }] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + expect(onPayload).toHaveBeenCalledWith({ + worktreePath: '/remote/repo', + events: [{ kind: 'update', absolutePath: '/remote/repo/readme.md' }] + }) + + stop() + expect(unsubscribe).toHaveBeenCalled() + }) + + it('shares one remote file watch subscription across listeners for the same worktree', async () => { + const firstPayload = vi.fn() + const secondPayload = vi.fn() + const unsubscribe = vi.fn() + let onResponse: ((response: unknown) => void) | undefined + runtimeEnvironmentCall.mockResolvedValue({ + id: 'unwatch', + ok: true, + result: { unsubscribed: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + runtimeEnvironmentSubscribe.mockImplementation((_args, callbacks) => { + onResponse = callbacks.onResponse + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + + const firstStop = await subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + firstPayload + ) + const secondStop = await subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + secondPayload + ) + + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(1) + onResponse?.({ + id: 'ready', + ok: true, + result: { type: 'ready', subscriptionId: 'files-watch-1' }, + _meta: { runtimeId: 'remote-runtime' } + }) + onResponse?.({ + id: 'changed', + ok: true, + result: { + type: 'changed', + worktree: 'wt-1', + events: [{ kind: 'update', absolutePath: '/remote/repo/readme.md' }] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + expect(firstPayload).toHaveBeenCalledWith({ + worktreePath: '/remote/repo', + events: [{ kind: 'update', absolutePath: '/remote/repo/readme.md' }] + }) + expect(secondPayload).toHaveBeenCalledWith({ + worktreePath: '/remote/repo', + events: [{ kind: 'update', absolutePath: '/remote/repo/readme.md' }] + }) + + firstStop() + expect(unsubscribe).not.toHaveBeenCalled() + + secondStop() + expect(unsubscribe).toHaveBeenCalledTimes(1) + await vi.waitFor(() => + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.unwatch', + params: { subscriptionId: 'files-watch-1' }, + timeoutMs: 5_000 + }) + ) + }) +}) diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts new file mode 100644 index 00000000000..233f641c61b --- /dev/null +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -0,0 +1,860 @@ +/* eslint-disable max-lines -- Why: this client intentionally centralizes the +file preload API plus remote runtime fallbacks so call sites cannot drift on +local-vs-environment routing rules. */ +import type { + DirEntry, + FsChangedPayload, + GlobalSettings, + MarkdownDocument, + SearchOptions, + SearchResult +} from '../../../shared/types' +import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../../shared/runtime-types' +import { + callRuntimeRpc, + getActiveRuntimeTarget, + unwrapRuntimeRpcResult +} from './runtime-rpc-client' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { basename, joinPath, normalizeRelativePath } from '@/lib/path' +import { + isWindowsAbsolutePathLike, + relativePathInsideRoot +} from '../../../shared/cross-platform-path' + +export type RuntimeReadableFileContent = { + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string +} + +export type RuntimeFileReadArgs = { + settings: Pick | null | undefined + filePath: string + relativePath?: string + worktreeId?: string + connectionId?: string +} + +export type RuntimeFileOperationArgs = { + settings: Pick | null | undefined + worktreeId: string | null | undefined + worktreePath: string | null | undefined + connectionId?: string +} + +type StagedRuntimeImportSource = + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: StagedRuntimeImportEntry[] + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { sourcePath: string; status: 'failed'; reason: string } + +type StagedRuntimeImportEntry = + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + +type RuntimeImportResult = + | { + sourcePath: string + status: 'imported' + destPath: string + kind: 'file' | 'directory' + renamed: boolean + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + +type RuntimeFileWatchEvent = + | { type: 'ready'; subscriptionId: string } + | { type: 'changed'; worktree: string; events: FsChangedPayload['events'] } + | { type: 'end' } + +const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024 + +type RuntimeFileWatchListener = { + onPayload: (payload: FsChangedPayload) => void + onError?: (error: Error) => void +} + +type SharedRuntimeFileWatch = { + target: { kind: 'environment'; environmentId: string } + worktreeId: string + listeners: Set + start: Promise + unsubscribe: (() => void) | null + remoteSubscriptionId: string | null + closed: boolean +} + +const sharedRuntimeFileWatches = new Map() + +function getSharedRuntimeFileWatchKey( + environmentId: string, + worktreeId: string, + worktreePath: string +): string { + return `${environmentId}\0${worktreeId}\0${worktreePath}` +} + +export function getRuntimeFileReadScope( + settings: Pick | null | undefined, + connectionId: string | undefined +): string | undefined { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId +} + +export async function readRuntimeFileContent({ + settings, + filePath, + relativePath, + worktreeId, + connectionId +}: RuntimeFileReadArgs): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return window.api.fs.readFile({ filePath, connectionId }) + } + if (!worktreeId) { + return window.api.fs.readFile({ filePath, connectionId }) + } + if (!canReadRelativeRuntimeFile(relativePath)) { + throw new Error('Remote file is outside the owning runtime worktree') + } + + const result = await callRuntimeRpc( + target, + 'files.read', + { worktree: worktreeId, relativePath }, + { timeoutMs: 15_000 } + ) + if (result.truncated) { + // Why: the runtime file RPC is preview-sized today; treating a truncated + // payload as editable content would make saves overwrite the rest of the file. + throw new Error(`Remote file is too large to open in the editor (${result.byteLength} bytes)`) + } + return { content: result.content, isBinary: false } +} + +export async function readRuntimeFilePreview( + context: RuntimeFileOperationArgs, + filePath: string +): Promise { + const remoteArgs = getRemoteFileArgs(context, filePath) + if (!remoteArgs) { + if (hasRemoteRuntimeOwner(context)) { + throw new Error('Remote file is outside the owning runtime worktree') + } + return window.api.fs.readFile({ filePath, connectionId: context.connectionId }) + } + return callRuntimeRpc( + remoteArgs.target, + 'files.readPreview', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) +} + +export async function readRuntimeDirectory( + context: RuntimeFileOperationArgs, + dirPath: string +): Promise { + const remoteArgs = getRemoteFileArgs(context, dirPath) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + return window.api.fs.readDir({ dirPath, connectionId: context.connectionId }) + } + return callRuntimeRpc( + remoteArgs.target, + 'files.readDir', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) +} + +export async function writeRuntimeFile( + context: RuntimeFileOperationArgs, + filePath: string, + content: string +): Promise { + const remoteArgs = getRemoteFileArgs(context, filePath) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + await window.api.fs.writeFile({ filePath, content, connectionId: context.connectionId }) + return + } + await callRuntimeRpc( + remoteArgs.target, + 'files.write', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath, content }, + { timeoutMs: 15_000 } + ) +} + +export async function createRuntimePath( + context: RuntimeFileOperationArgs, + path: string, + kind: 'file' | 'directory' +): Promise { + const remoteArgs = getRemoteFileArgs(context, path) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + await (kind === 'directory' + ? window.api.fs.createDir({ dirPath: path, connectionId: context.connectionId }) + : window.api.fs.createFile({ filePath: path, connectionId: context.connectionId })) + return + } + await callRuntimeRpc( + remoteArgs.target, + kind === 'directory' ? 'files.createDir' : 'files.createFile', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) +} + +export async function renameRuntimePath( + context: RuntimeFileOperationArgs, + oldPath: string, + newPath: string +): Promise { + const oldRemoteArgs = getRemoteFileArgs(context, oldPath) + const newRelativePath = getRelativePathInsideWorktree(context.worktreePath, newPath) + if (!oldRemoteArgs || newRelativePath === null) { + assertLocalFilesystemFallbackAllowed(context) + await window.api.fs.rename({ oldPath, newPath, connectionId: context.connectionId }) + return + } + await callRuntimeRpc( + oldRemoteArgs.target, + 'files.rename', + { + worktree: oldRemoteArgs.worktreeId, + oldRelativePath: oldRemoteArgs.relativePath, + newRelativePath + }, + { timeoutMs: 15_000 } + ) +} + +export async function copyRuntimePath( + context: RuntimeFileOperationArgs, + sourcePath: string, + destinationPath: string +): Promise { + const sourceArgs = getRemoteFileArgs(context, sourcePath) + const destinationArgs = getRemoteFileArgs(context, destinationPath) + if (!sourceArgs || !destinationArgs) { + assertLocalFilesystemFallbackAllowed(context) + await window.api.fs.copy({ + sourcePath, + destinationPath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + sourceArgs.target, + 'files.copy', + { + worktree: sourceArgs.worktreeId, + sourceRelativePath: sourceArgs.relativePath, + destinationRelativePath: destinationArgs.relativePath + }, + { timeoutMs: 15_000 } + ) +} + +export async function deleteRuntimePath( + context: RuntimeFileOperationArgs, + targetPath: string, + recursive?: boolean +): Promise { + const remoteArgs = getRemoteFileArgs(context, targetPath) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + await window.api.fs.deletePath({ + targetPath, + connectionId: context.connectionId, + recursive + }) + return + } + await callRuntimeRpc( + remoteArgs.target, + 'files.delete', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath, recursive }, + { timeoutMs: 15_000 } + ) +} + +export async function deleteRuntimeRelativePath( + context: RuntimeFileOperationArgs, + relativePath: string, + recursive?: boolean +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if ( + target.kind !== 'environment' || + !context.worktreeId || + !canReadRelativeRuntimeFile(relativePath) + ) { + return false + } + await callRuntimeRpc( + target, + 'files.delete', + { worktree: context.worktreeId, relativePath: normalizeRelativePath(relativePath), recursive }, + { timeoutMs: 15_000 } + ) + return true +} + +export async function importExternalPathsToRuntime( + context: RuntimeFileOperationArgs, + sourcePaths: string[], + destinationDir: string, + options?: { ensureDestinationDir?: boolean } +): Promise<{ results: RuntimeImportResult[] }> { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { + return window.api.fs.importExternalPaths({ + sourcePaths, + destDir: destinationDir, + connectionId: context.connectionId, + ensureDir: options?.ensureDestinationDir + }) + } + + const destinationArgs = getRemoteFileArgs(context, destinationDir) + if (!destinationArgs) { + throw new Error('Destination is outside the active runtime worktree') + } + + const staged = await window.api.fs.stageExternalPathsForRuntimeUpload({ sourcePaths }) + const results: RuntimeImportResult[] = [] + const reservedNames = new Set() + + await ensureRuntimeDirectory(context, destinationDir) + + for (const source of staged.sources as StagedRuntimeImportSource[]) { + if (source.status !== 'staged') { + results.push(source) + continue + } + try { + const finalName = await deconflictRuntimeImportName( + context, + destinationDir, + source.name, + reservedNames + ) + const destPath = joinPath(destinationDir, finalName) + const destRelativePath = joinRuntimeRelativePath(destinationArgs.relativePath, finalName) + for (const entry of source.entries) { + const entryRelativePath = joinRuntimeRelativePath(destRelativePath, entry.relativePath) + if (entry.kind === 'directory') { + await callRuntimeRpc( + target, + 'files.createDirNoClobber', + { worktree: context.worktreeId, relativePath: entryRelativePath }, + { timeoutMs: 15_000 } + ) + continue + } + await uploadRuntimeFileWithoutClobber( + target, + context.worktreeId, + entryRelativePath, + entry.contentBase64 + ) + } + reservedNames.add(finalName) + results.push({ + sourcePath: source.sourcePath, + status: 'imported', + destPath, + kind: source.kind, + renamed: finalName !== source.name + }) + } catch (error) { + results.push({ + sourcePath: source.sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + }) + } + } + + return { results } +} + +async function uploadRuntimeFileWithoutClobber( + target: { kind: 'environment'; environmentId: string }, + worktreeId: string, + relativePath: string, + contentBase64: string +): Promise { + const tempRelativePath = makeRuntimeUploadTempPath(relativePath) + try { + await writeRuntimeBase64File(target, worktreeId, tempRelativePath, contentBase64) + await callRuntimeRpc( + target, + 'files.commitUpload', + { + worktree: worktreeId, + tempRelativePath, + finalRelativePath: relativePath + }, + { timeoutMs: 30_000 } + ) + } finally { + await callRuntimeRpc( + target, + 'files.delete', + { worktree: worktreeId, relativePath: tempRelativePath, recursive: false }, + { timeoutMs: 15_000 } + ).catch(() => {}) + } +} + +async function writeRuntimeBase64File( + target: { kind: 'environment'; environmentId: string }, + worktreeId: string, + relativePath: string, + contentBase64: string +): Promise { + if (contentBase64.length <= REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { + await callRuntimeRpc( + target, + 'files.writeBase64', + { worktree: worktreeId, relativePath, contentBase64 }, + { timeoutMs: 30_000 } + ) + return + } + + for (let offset = 0; offset < contentBase64.length; offset += REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { + await callRuntimeRpc( + target, + 'files.writeBase64Chunk', + { + worktree: worktreeId, + relativePath, + contentBase64: contentBase64.slice(offset, offset + REMOTE_UPLOAD_BASE64_CHUNK_CHARS), + append: offset > 0 + }, + { timeoutMs: 30_000 } + ) + } +} + +function makeRuntimeUploadTempPath(relativePath: string): string { + const normalized = normalizeRelativePath(relativePath) + const slashIndex = normalized.lastIndexOf('/') + const dir = slashIndex === -1 ? '' : normalized.slice(0, slashIndex + 1) + const leaf = slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1) + const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + return `${dir}.${leaf}.orca-upload-${nonce}` +} + +async function ensureRuntimeDirectory( + context: RuntimeFileOperationArgs, + destinationDir: string +): Promise { + const destinationArgs = getRemoteFileArgs(context, destinationDir) + if (!destinationArgs) { + return + } + const parts = normalizeRelativePath(destinationArgs.relativePath) + .split('/') + .filter((part) => part.length > 0) + let current = '' + for (const part of parts) { + current = joinRuntimeRelativePath(current, part) + const absolutePath = joinPath(context.worktreePath ?? '', current) + if (await runtimePathExists(context, absolutePath)) { + continue + } + await callRuntimeRpc( + destinationArgs.target, + 'files.createDir', + { worktree: destinationArgs.worktreeId, relativePath: current }, + { timeoutMs: 15_000 } + ) + } +} + +export async function searchRuntimeFiles( + context: RuntimeFileOperationArgs, + options: SearchOptions +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId) { + return window.api.fs.search({ + ...options, + connectionId: context.connectionId + }) + } + const { rootPath: _rootPath, ...runtimeOptions } = options + return callRuntimeRpc( + target, + 'files.search', + { worktree: context.worktreeId, ...runtimeOptions }, + { timeoutMs: 15_000 } + ) +} + +export async function listRuntimeFiles( + context: RuntimeFileOperationArgs, + args: { rootPath: string; excludePaths?: string[] } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId) { + return window.api.fs.listFiles({ + rootPath: args.rootPath, + connectionId: context.connectionId, + excludePaths: args.excludePaths + }) + } + return callRuntimeRpc( + target, + 'files.listAll', + { worktree: context.worktreeId, excludePaths: args.excludePaths }, + { timeoutMs: 15_000 } + ) +} + +export async function listRuntimeMarkdownDocuments( + context: RuntimeFileOperationArgs, + rootPath: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId) { + return window.api.fs.listMarkdownDocuments({ + rootPath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'files.listMarkdownDocuments', + { worktree: context.worktreeId }, + { timeoutMs: 15_000 } + ) +} + +export async function statRuntimePath( + context: RuntimeFileOperationArgs, + absolutePath: string +): Promise<{ size: number; isDirectory: boolean; mtime: number }> { + const remoteArgs = getRemoteFileArgs(context, absolutePath) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + return window.api.fs.stat({ + filePath: absolutePath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc<{ size: number; isDirectory: boolean; mtime: number }>( + remoteArgs.target, + 'files.stat', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) +} + +export async function subscribeRuntimeFileChanges( + context: RuntimeFileOperationArgs, + onPayload: (payload: FsChangedPayload) => void, + onError?: (error: Error) => void +): Promise<() => void> { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { + return window.api.fs.onFsChanged(onPayload) + } + + const listener: RuntimeFileWatchListener = { onPayload, onError } + const key = getSharedRuntimeFileWatchKey( + target.environmentId, + context.worktreeId, + context.worktreePath + ) + let shared = sharedRuntimeFileWatches.get(key) + if (!shared) { + shared = createSharedRuntimeFileWatch(key, target, context.worktreeId, context.worktreePath) + sharedRuntimeFileWatches.set(key, shared) + } + shared.listeners.add(listener) + try { + await shared.start + } catch (err) { + shared.listeners.delete(listener) + throw err + } + + return () => { + const current = sharedRuntimeFileWatches.get(key) + if (!current) { + return + } + current.listeners.delete(listener) + if (current.listeners.size === 0) { + closeSharedRuntimeFileWatch(key, current) + } + } +} + +function createSharedRuntimeFileWatch( + key: string, + target: { kind: 'environment'; environmentId: string }, + worktreeId: string, + worktreePath: string +): SharedRuntimeFileWatch { + const shared: SharedRuntimeFileWatch = { + target, + worktreeId, + listeners: new Set(), + start: Promise.resolve(), + unsubscribe: null, + remoteSubscriptionId: null, + closed: false + } + // Why: editor reloads and the Explorer can watch the same remote worktree. + // Keep one runtime WebSocket/server watcher and fan out events in renderer. + shared.start = window.api.runtimeEnvironments + .subscribe( + { + selector: target.environmentId, + method: 'files.watch', + params: { worktree: worktreeId }, + timeoutMs: 15_000 + }, + { + onResponse: (response) => { + handleSharedRuntimeFileWatchResponse(shared, worktreePath, response) + }, + onError: (error) => { + notifySharedRuntimeFileWatchError(shared, new Error(error.message)) + }, + onClose: () => { + if (sharedRuntimeFileWatches.get(key) === shared) { + sharedRuntimeFileWatches.delete(key) + } + shared.closed = true + shared.unsubscribe = null + } + } + ) + .then((subscription) => { + if (shared.closed || sharedRuntimeFileWatches.get(key) !== shared) { + subscription.unsubscribe() + unwatchSharedRuntimeFileWatch(shared) + return + } + shared.unsubscribe = subscription.unsubscribe + }) + .catch((err) => { + if (sharedRuntimeFileWatches.get(key) === shared) { + sharedRuntimeFileWatches.delete(key) + } + shared.closed = true + notifySharedRuntimeFileWatchError(shared, err instanceof Error ? err : new Error(String(err))) + throw err + }) + return shared +} + +function handleSharedRuntimeFileWatchResponse( + shared: SharedRuntimeFileWatch, + worktreePath: string, + response: unknown +): void { + try { + const event = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + if (event.type === 'ready') { + shared.remoteSubscriptionId = event.subscriptionId + } else if (event.type === 'changed') { + for (const listener of Array.from(shared.listeners)) { + listener.onPayload({ worktreePath, events: event.events }) + } + } + } catch (err) { + notifySharedRuntimeFileWatchError(shared, err instanceof Error ? err : new Error(String(err))) + } +} + +function notifySharedRuntimeFileWatchError(shared: SharedRuntimeFileWatch, error: Error): void { + for (const listener of Array.from(shared.listeners)) { + listener.onError?.(error) + } +} + +function closeSharedRuntimeFileWatch(key: string, shared: SharedRuntimeFileWatch): void { + if (shared.closed) { + return + } + shared.closed = true + sharedRuntimeFileWatches.delete(key) + shared.unsubscribe?.() + shared.unsubscribe = null + unwatchSharedRuntimeFileWatch(shared) +} + +function unwatchSharedRuntimeFileWatch(shared: SharedRuntimeFileWatch): void { + if (!shared.remoteSubscriptionId) { + return + } + void callRuntimeRpc( + shared.target, + 'files.unwatch', + { subscriptionId: shared.remoteSubscriptionId }, + { timeoutMs: 5_000 } + ).catch(() => {}) +} + +export async function runtimePathExists( + context: RuntimeFileOperationArgs, + absolutePath: string +): Promise { + try { + await statRuntimePath(context, absolutePath) + return true + } catch (err) { + const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase() + if ( + message.includes('enoent') || + message.includes('not found') || + message.includes('no such file') + ) { + return false + } + throw err + } +} + +export function isRemoteRuntimeFileOperation( + context: RuntimeFileOperationArgs, + path: string +): boolean { + return getRemoteFileArgs(context, path) !== null +} + +function canReadRelativeRuntimeFile(relativePath: string | undefined): relativePath is string { + return Boolean(relativePath && relativePath.trim() && !isAbsolutePathLike(relativePath)) +} + +function isAbsolutePathLike(value: string): boolean { + return value.startsWith('/') || isWindowsAbsolutePathLike(value) +} + +function getRemoteFileArgs( + context: RuntimeFileOperationArgs, + absolutePath: string +): { + target: ReturnType & { kind: 'environment' } + worktreeId: string + relativePath: string +} | null { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId) { + return null + } + const relativePath = getRelativePathInsideWorktree(context.worktreePath, absolutePath) + if (relativePath === null) { + return null + } + return { target, worktreeId: context.worktreeId, relativePath } +} + +function hasRemoteRuntimeOwner(context: RuntimeFileOperationArgs): boolean { + return ( + getActiveRuntimeTarget(context.settings).kind === 'environment' && Boolean(context.worktreeId) + ) +} + +function assertLocalFilesystemFallbackAllowed(context: RuntimeFileOperationArgs): void { + if (hasRemoteRuntimeOwner(context)) { + throw new Error('Remote file is outside the owning runtime worktree') + } +} + +function getRelativePathInsideWorktree( + worktreePath: string | null | undefined, + absolutePath: string +): string | null { + if (!worktreePath) { + return null + } + return relativePathInsideRoot(worktreePath, absolutePath) +} + +async function deconflictRuntimeImportName( + context: RuntimeFileOperationArgs, + destinationDir: string, + originalName: string, + reservedNames: Set +): Promise { + if ( + !(await runtimePathExists(context, joinPath(destinationDir, originalName))) && + !reservedNames.has(originalName) + ) { + return originalName + } + + const dotIndex = originalName.lastIndexOf('.') + const hasMeaningfulExt = dotIndex > 0 + const stem = hasMeaningfulExt ? originalName.slice(0, dotIndex) : originalName + const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : '' + let candidate = `${stem} copy${ext}` + if ( + !(await runtimePathExists(context, joinPath(destinationDir, candidate))) && + !reservedNames.has(candidate) + ) { + return candidate + } + + let counter = 2 + while (counter < 10000) { + candidate = `${stem} copy ${counter}${ext}` + if ( + !(await runtimePathExists(context, joinPath(destinationDir, candidate))) && + !reservedNames.has(candidate) + ) { + return candidate + } + counter += 1 + } + throw new Error(`Could not generate a unique name for '${basename(originalName)}'`) +} + +function joinRuntimeRelativePath(basePath: string, relativePath: string): string { + const normalizedBase = normalizeRelativePath(basePath) + const normalizedRelative = normalizeRelativePath(relativePath) + if (!normalizedBase) { + return normalizedRelative + } + if (!normalizedRelative) { + return normalizedBase + } + return `${normalizedBase}/${normalizedRelative}` +} diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts new file mode 100644 index 00000000000..19888c32ae8 --- /dev/null +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + bulkStageRuntimeGitPaths, + commitRuntimeGit, + getRuntimeGitDiff, + getRuntimeGitStatus, + pushRuntimeGit +} from './runtime-git-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const gitStatus = vi.fn() +const gitDiff = vi.fn() +const gitBulkStage = vi.fn() +const gitCommit = vi.fn() +const gitPush = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const runtimeCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + gitStatus.mockReset() + gitDiff.mockReset() + gitBulkStage.mockReset() + gitCommit.mockReset() + gitPush.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + git: { + status: gitStatus, + diff: gitDiff, + bulkStage: gitBulkStage, + commit: gitCommit, + push: gitPush + }, + runtime: { call: runtimeCall }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('runtime git client', () => { + it('uses local git IPC when no remote runtime is active', async () => { + gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await getRuntimeGitStatus({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }) + + expect(gitStatus).toHaveBeenCalledWith({ worktreePath: '/repo', connectionId: 'ssh-1' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes status and diffs through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + await getRuntimeGitDiff( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { filePath: 'src/a.ts', staged: false, compareAgainstHead: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'git.status', + params: { worktree: 'wt-1' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'git.diff', + params: { + worktree: 'wt-1', + filePath: 'src/a.ts', + staged: false, + compareAgainstHead: true + }, + timeoutMs: 15_000 + }) + }) + + it('routes bulk stage and remote operations through the active runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { success: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + } + + await bulkStageRuntimeGitPaths(context, ['a.ts', 'b.ts']) + await commitRuntimeGit(context, 'feat: test') + await pushRuntimeGit(context, { publish: true, pushTarget: { remote: 'origin' } as never }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'git.bulkStage', + params: { worktree: 'wt-1', filePaths: ['a.ts', 'b.ts'] }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'git.commit', + params: { worktree: 'wt-1', message: 'feat: test' }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'git.push', + params: { worktree: 'wt-1', publish: true, pushTarget: { remote: 'origin' } }, + timeoutMs: 30_000 + }) + }) +}) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts new file mode 100644 index 00000000000..759edbd624c --- /dev/null +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -0,0 +1,340 @@ +/* eslint-disable max-lines -- Why: this module mirrors the git preload API with +runtime-aware routing so source-control callers have one typed boundary instead +of reimplementing local-vs-environment branching per operation. */ +import type { + GitBranchCompareResult, + GitConflictOperation, + GitDiffResult, + GitPushTarget, + GitStatusResult, + GitUpstreamStatus, + GlobalSettings +} from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' + +export type RuntimeGitContext = { + settings: Pick | null | undefined + worktreeId: string | null | undefined + worktreePath: string + connectionId?: string +} + +export function getRuntimeGitScope( + settings: Pick | null | undefined, + connectionId: string | undefined +): string | undefined { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId +} + +export async function getRuntimeGitStatus(context: RuntimeGitContext): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.status({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.status', + { worktree: context.worktreeId }, + { timeoutMs: 15_000 } + ) +} + +export async function getRuntimeGitConflictOperation( + context: RuntimeGitContext +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.conflictOperation({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.conflictOperation', + { worktree: context.worktreeId }, + { timeoutMs: 15_000 } + ) +} + +export async function getRuntimeGitDiff( + context: RuntimeGitContext, + args: { filePath: string; staged: boolean; compareAgainstHead?: boolean } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.diff({ + worktreePath: context.worktreePath, + filePath: args.filePath, + staged: args.staged, + compareAgainstHead: args.compareAgainstHead, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.diff', + { worktree: context.worktreeId, ...args }, + { timeoutMs: 15_000 } + ) +} + +export async function getRuntimeGitBranchCompare( + context: RuntimeGitContext, + baseRef: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.branchCompare({ + worktreePath: context.worktreePath, + baseRef, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.branchCompare', + { worktree: context.worktreeId, baseRef }, + { timeoutMs: 15_000 } + ) +} + +export async function getRuntimeGitUpstreamStatus( + context: RuntimeGitContext +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.upstreamStatus({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.upstreamStatus', + { worktree: context.worktreeId }, + { timeoutMs: 15_000 } + ) +} + +export async function fetchRuntimeGit(context: RuntimeGitContext): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.fetch({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc(target, 'git.fetch', { worktree: context.worktreeId }, { timeoutMs: 30_000 }) +} + +export async function pullRuntimeGit(context: RuntimeGitContext): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.pull({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc(target, 'git.pull', { worktree: context.worktreeId }, { timeoutMs: 30_000 }) +} + +export async function pushRuntimeGit( + context: RuntimeGitContext, + args: { publish?: boolean; pushTarget?: GitPushTarget } = {} +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.push({ + worktreePath: context.worktreePath, + publish: args.publish, + pushTarget: args.pushTarget, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.push', + { worktree: context.worktreeId, publish: args.publish, pushTarget: args.pushTarget }, + { timeoutMs: 30_000 } + ) +} + +export async function getRuntimeGitBranchDiff( + context: RuntimeGitContext, + args: { + compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } + filePath: string + oldPath?: string + } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.branchDiff({ + worktreePath: context.worktreePath, + compare: args.compare, + filePath: args.filePath, + oldPath: args.oldPath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.branchDiff', + { worktree: context.worktreeId, ...args }, + { timeoutMs: 15_000 } + ) +} + +export async function commitRuntimeGit( + context: RuntimeGitContext, + message: string +): Promise<{ success: boolean; error?: string }> { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.commit({ + worktreePath: context.worktreePath, + message, + connectionId: context.connectionId + }) + } + return callRuntimeRpc<{ success: boolean; error?: string }>( + target, + 'git.commit', + { worktree: context.worktreeId, message }, + { timeoutMs: 30_000 } + ) +} + +export async function stageRuntimeGitPath( + context: RuntimeGitContext, + filePath: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.stage({ + worktreePath: context.worktreePath, + filePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.stage', + { worktree: context.worktreeId, filePath }, + { timeoutMs: 15_000 } + ) +} + +export async function bulkStageRuntimeGitPaths( + context: RuntimeGitContext, + filePaths: string[] +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.bulkStage({ + worktreePath: context.worktreePath, + filePaths, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.bulkStage', + { worktree: context.worktreeId, filePaths }, + { timeoutMs: 15_000 } + ) +} + +export async function unstageRuntimeGitPath( + context: RuntimeGitContext, + filePath: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.unstage({ + worktreePath: context.worktreePath, + filePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.unstage', + { worktree: context.worktreeId, filePath }, + { timeoutMs: 15_000 } + ) +} + +export async function bulkUnstageRuntimeGitPaths( + context: RuntimeGitContext, + filePaths: string[] +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.bulkUnstage({ + worktreePath: context.worktreePath, + filePaths, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.bulkUnstage', + { worktree: context.worktreeId, filePaths }, + { timeoutMs: 15_000 } + ) +} + +export async function discardRuntimeGitPath( + context: RuntimeGitContext, + filePath: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.discard({ + worktreePath: context.worktreePath, + filePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.discard', + { worktree: context.worktreeId, filePath }, + { timeoutMs: 15_000 } + ) +} + +export async function getRuntimeGitRemoteFileUrl( + context: RuntimeGitContext, + args: { relativePath: string; line: number } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.remoteFileUrl({ + worktreePath: context.worktreePath, + relativePath: args.relativePath, + line: args.line, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.remoteFileUrl', + { worktree: context.worktreeId, relativePath: args.relativePath, line: args.line }, + { timeoutMs: 15_000 } + ) +} diff --git a/src/renderer/src/runtime/runtime-hooks-client.test.ts b/src/renderer/src/runtime/runtime-hooks-client.test.ts new file mode 100644 index 00000000000..53c9d6891a1 --- /dev/null +++ b/src/renderer/src/runtime/runtime-hooks-client.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + checkRuntimeHooks, + readRuntimeIssueCommand, + writeRuntimeIssueCommand +} from './runtime-hooks-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const hooksCheck = vi.fn() +const hooksReadIssueCommand = vi.fn() +const hooksWriteIssueCommand = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + hooksCheck.mockReset() + hooksReadIssueCommand.mockReset() + hooksWriteIssueCommand.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + hooks: { + check: hooksCheck, + readIssueCommand: hooksReadIssueCommand, + writeIssueCommand: hooksWriteIssueCommand + } + } + }) +}) + +describe('runtime hooks client', () => { + it('uses local hook IPC when no runtime environment is active', async () => { + hooksCheck.mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false }) + hooksReadIssueCommand.mockResolvedValue({ + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' + }) + + await checkRuntimeHooks({ activeRuntimeEnvironmentId: null }, 'repo-1') + await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'repo-1') + await writeRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'repo-1', 'Fix it') + + expect(hooksCheck).toHaveBeenCalledWith({ repoId: 'repo-1' }) + expect(hooksReadIssueCommand).toHaveBeenCalledWith({ repoId: 'repo-1' }) + expect(hooksWriteIssueCommand).toHaveBeenCalledWith({ repoId: 'repo-1', content: 'Fix it' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes hook operations through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'runtime-1' } + }) + + await checkRuntimeHooks({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1') + await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1') + await writeRuntimeIssueCommand({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1', 'Fix it') + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'repo.hooksCheck', + params: { repo: 'repo-1' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'repo.issueCommandRead', + params: { repo: 'repo-1' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'repo.issueCommandWrite', + params: { repo: 'repo-1', content: 'Fix it' }, + timeoutMs: 15_000 + }) + expect(hooksCheck).not.toHaveBeenCalled() + expect(hooksReadIssueCommand).not.toHaveBeenCalled() + expect(hooksWriteIssueCommand).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-hooks-client.ts b/src/renderer/src/runtime/runtime-hooks-client.ts new file mode 100644 index 00000000000..3179de7eef0 --- /dev/null +++ b/src/renderer/src/runtime/runtime-hooks-client.ts @@ -0,0 +1,66 @@ +import type { GlobalSettings, OrcaHooks } from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' + +export type HookCheckResult = { + hasHooks: boolean + hooks: OrcaHooks | null + mayNeedUpdate: boolean +} + +export type IssueCommandReadResult = { + localContent: string | null + sharedContent: string | null + effectiveContent: string | null + localFilePath: string + source: 'local' | 'shared' | 'none' +} + +export async function checkRuntimeHooks( + settings: Pick | null | undefined, + repoId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return window.api.hooks.check({ repoId }) + } + return callRuntimeRpc( + target, + 'repo.hooksCheck', + { repo: repoId }, + { timeoutMs: 15_000 } + ) +} + +export async function readRuntimeIssueCommand( + settings: Pick | null | undefined, + repoId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return window.api.hooks.readIssueCommand({ repoId }) + } + return callRuntimeRpc( + target, + 'repo.issueCommandRead', + { repo: repoId }, + { timeoutMs: 15_000 } + ) +} + +export async function writeRuntimeIssueCommand( + settings: Pick | null | undefined, + repoId: string, + content: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + await window.api.hooks.writeIssueCommand({ repoId, content }) + return + } + await callRuntimeRpc( + target, + 'repo.issueCommandWrite', + { repo: repoId, content }, + { timeoutMs: 15_000 } + ) +} diff --git a/src/renderer/src/runtime/runtime-linear-client.test.ts b/src/renderer/src/runtime/runtime-linear-client.test.ts new file mode 100644 index 00000000000..a552db06e19 --- /dev/null +++ b/src/renderer/src/runtime/runtime-linear-client.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + linearCreateIssue, + linearListTeams, + linearSearchIssues, + linearStatus, + linearUpdateIssue +} from './runtime-linear-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const linearStatusLocal = vi.fn() +const linearSearchIssuesLocal = vi.fn() +const linearCreateIssueLocal = vi.fn() +const linearUpdateIssueLocal = vi.fn() +const linearListTeamsLocal = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + linearStatusLocal.mockReset() + linearSearchIssuesLocal.mockReset() + linearCreateIssueLocal.mockReset() + linearUpdateIssueLocal.mockReset() + linearListTeamsLocal.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + linear: { + status: linearStatusLocal, + searchIssues: linearSearchIssuesLocal, + createIssue: linearCreateIssueLocal, + updateIssue: linearUpdateIssueLocal, + listTeams: linearListTeamsLocal + } + } + }) +}) + +describe('runtime linear client', () => { + it('uses local Linear IPC when no runtime environment is active', async () => { + linearStatusLocal.mockResolvedValue({ connected: false, viewer: null }) + linearSearchIssuesLocal.mockResolvedValue([{ id: 'issue-1' }]) + + await expect(linearStatus({ activeRuntimeEnvironmentId: null })).resolves.toEqual({ + connected: false, + viewer: null + }) + await expect( + linearSearchIssues({ activeRuntimeEnvironmentId: null }, 'bug', 10) + ).resolves.toEqual([{ id: 'issue-1' }]) + + expect(linearStatusLocal).toHaveBeenCalled() + expect(linearSearchIssuesLocal).toHaveBeenCalledWith({ query: 'bug', limit: 10 }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes Linear reads through the selected runtime environment', async () => { + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'rpc-status', + ok: true, + result: { connected: true, viewer: null }, + _meta: { runtimeId: 'runtime-1' } + }) + .mockResolvedValueOnce({ + id: 'rpc-search', + ok: true, + result: [{ id: 'issue-1' }], + _meta: { runtimeId: 'runtime-1' } + }) + + await linearStatus({ activeRuntimeEnvironmentId: 'env-1' }) + await linearSearchIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'bug', 10) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'linear.status', + params: undefined, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'linear.searchIssues', + params: { query: 'bug', limit: 10 }, + timeoutMs: 30_000 + }) + expect(linearStatusLocal).not.toHaveBeenCalled() + expect(linearSearchIssuesLocal).not.toHaveBeenCalled() + }) + + it('routes Linear mutations and metadata through the selected runtime environment', async () => { + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'rpc-create', + ok: true, + result: { ok: true, id: 'issue-1', identifier: 'ENG-1', url: 'https://linear.app/ENG-1' }, + _meta: { runtimeId: 'runtime-1' } + }) + .mockResolvedValueOnce({ + id: 'rpc-update', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'runtime-1' } + }) + .mockResolvedValueOnce({ + id: 'rpc-teams', + ok: true, + result: [{ id: 'team-1' }], + _meta: { runtimeId: 'runtime-1' } + }) + + await linearCreateIssue( + { activeRuntimeEnvironmentId: 'env-1' }, + { teamId: 'team-1', title: 'Fix bug' } + ) + await linearUpdateIssue({ activeRuntimeEnvironmentId: 'env-1' }, 'issue-1', { priority: 2 }) + await linearListTeams({ activeRuntimeEnvironmentId: 'env-1' }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'linear.createIssue', + params: { teamId: 'team-1', title: 'Fix bug' }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'linear.updateIssue', + params: { id: 'issue-1', updates: { priority: 2 } }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'linear.listTeams', + params: undefined, + timeoutMs: 30_000 + }) + }) +}) diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts new file mode 100644 index 00000000000..7821a46d8aa --- /dev/null +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -0,0 +1,222 @@ +import type { + GlobalSettings, + LinearComment, + LinearConnectionStatus, + LinearIssue, + LinearIssueUpdate, + LinearLabel, + LinearMember, + LinearTeam, + LinearViewer, + LinearWorkflowState +} from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' + +export type RuntimeLinearSettings = + | Pick + | null + | undefined + +export type LinearIssueFilter = 'assigned' | 'created' | 'all' | 'completed' +export type LinearConnectResult = { ok: true; viewer: LinearViewer } | { ok: false; error: string } +export type LinearCreateIssueResult = + | { ok: true; id: string; identifier: string; url: string } + | { ok: false; error: string } +export type LinearMutationResult = { ok: true } | { ok: false; error: string } +export type LinearCommentResult = { ok: true; id: string } | { ok: false; error: string } + +export async function linearStatus( + settings: RuntimeLinearSettings +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.status', undefined, { + timeoutMs: 15_000 + }) + : window.api.linear.status() +} + +export async function linearTestConnection( + settings: RuntimeLinearSettings +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.testConnection', undefined, { + timeoutMs: 30_000 + }) + : window.api.linear.testConnection() +} + +export async function linearConnect( + settings: RuntimeLinearSettings, + apiKey: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.connect', + { apiKey }, + { timeoutMs: 30_000 } + ) + : window.api.linear.connect({ apiKey }) +} + +export async function linearDisconnect(settings: RuntimeLinearSettings): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + await callRuntimeRpc<{ ok: true }>(target, 'linear.disconnect', undefined, { + timeoutMs: 15_000 + }) + return + } + await window.api.linear.disconnect() +} + +export async function linearSearchIssues( + settings: RuntimeLinearSettings, + query: string, + limit?: number +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.searchIssues', + { query, limit }, + { timeoutMs: 30_000 } + ) + : window.api.linear.searchIssues({ query, limit }) +} + +export async function linearListIssues( + settings: RuntimeLinearSettings, + filter?: LinearIssueFilter, + limit?: number +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.listIssues', + { filter, limit }, + { timeoutMs: 30_000 } + ) + : window.api.linear.listIssues({ filter, limit }) +} + +export async function linearCreateIssue( + settings: RuntimeLinearSettings, + args: { teamId: string; title: string; description?: string } +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.createIssue', args, { + timeoutMs: 30_000 + }) + : window.api.linear.createIssue(args) +} + +export async function linearGetIssue( + settings: RuntimeLinearSettings, + id: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.getIssue', { id }, { timeoutMs: 30_000 }) + : window.api.linear.getIssue({ id }) +} + +export async function linearUpdateIssue( + settings: RuntimeLinearSettings, + id: string, + updates: LinearIssueUpdate +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.updateIssue', + { id, updates }, + { timeoutMs: 30_000 } + ) + : window.api.linear.updateIssue({ id, updates }) +} + +export async function linearAddIssueComment( + settings: RuntimeLinearSettings, + issueId: string, + body: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.addIssueComment', + { issueId, body }, + { timeoutMs: 30_000 } + ) + : window.api.linear.addIssueComment({ issueId, body }) +} + +export async function linearIssueComments( + settings: RuntimeLinearSettings, + issueId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.issueComments', + { issueId }, + { timeoutMs: 30_000 } + ) + : window.api.linear.issueComments({ issueId }) +} + +export async function linearListTeams(settings: RuntimeLinearSettings): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.listTeams', undefined, { timeoutMs: 30_000 }) + : window.api.linear.listTeams() +} + +export async function linearTeamStates( + settings: RuntimeLinearSettings, + teamId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.teamStates', + { teamId }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamStates({ teamId }) +} + +export async function linearTeamLabels( + settings: RuntimeLinearSettings, + teamId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.teamLabels', { teamId }, { timeoutMs: 30_000 }) + : window.api.linear.teamLabels({ teamId }) +} + +export async function linearTeamMembers( + settings: RuntimeLinearSettings, + teamId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.teamMembers', + { teamId }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamMembers({ teamId }) +} diff --git a/src/renderer/src/runtime/runtime-notes-client.test.ts b/src/renderer/src/runtime/runtime-notes-client.test.ts new file mode 100644 index 00000000000..ee15a592106 --- /dev/null +++ b/src/renderer/src/runtime/runtime-notes-client.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createRuntimeProjectNote, + deleteRuntimeProjectNote, + linkRuntimeProjectNote, + listRuntimeProjectNotes, + renameRuntimeProjectNote, + resolveRuntimeNotesPanelState, + saveRuntimeProjectNote, + showRuntimeProjectNote +} from './runtime-notes-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const notesList = vi.fn() +const notesShow = vi.fn() +const notesCreate = vi.fn() +const notesSave = vi.fn() +const notesRename = vi.fn() +const notesDelete = vi.fn() +const notesAppend = vi.fn() +const notesSearch = vi.fn() +const notesLink = vi.fn() +const notesPanelState = vi.fn() +const runtimeCall = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + notesList.mockReset() + notesShow.mockReset() + notesCreate.mockReset() + notesSave.mockReset() + notesRename.mockReset() + notesDelete.mockReset() + notesAppend.mockReset() + notesSearch.mockReset() + notesLink.mockReset() + notesPanelState.mockReset() + runtimeCall.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + notes: { + list: notesList, + show: notesShow, + create: notesCreate, + save: notesSave, + rename: notesRename, + delete: notesDelete, + append: notesAppend, + search: notesSearch, + link: notesLink, + panelState: notesPanelState + }, + runtime: { call: runtimeCall }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('runtime notes client', () => { + it('uses local notes IPC when no remote runtime is active', async () => { + notesList.mockResolvedValue({ notes: [], totalCount: 0, truncated: false }) + + await listRuntimeProjectNotes( + { activeRuntimeEnvironmentId: null }, + { projectId: 'repo-1', worktreeId: 'wt-1', limit: 100 } + ) + + expect(notesList).toHaveBeenCalledWith({ + projectId: 'repo-1', + worktreeId: 'wt-1', + limit: 100 + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes note reads through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { notes: [], totalCount: 0, truncated: false }, + _meta: { runtimeId: 'remote-runtime' } + }) + const settings = { activeRuntimeEnvironmentId: 'env-1' } + + await listRuntimeProjectNotes(settings, { + projectId: 'repo-1', + worktreeId: 'wt-1', + limit: 100 + }) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-2', + ok: true, + result: { note: { id: 'note-1' }, linkKind: null }, + _meta: { runtimeId: 'remote-runtime' } + }) + await showRuntimeProjectNote(settings, { + projectId: 'repo-1', + worktreeId: 'wt-1', + note: 'note-1' + }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'note.list', + params: { worktree: 'wt-1', limit: 100 }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'note.show', + params: { worktree: 'wt-1', note: 'note-1' }, + timeoutMs: 15_000 + }) + }) + + it('routes note mutations through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { note: { id: 'note-1' }, linkKind: 'active' }, + _meta: { runtimeId: 'remote-runtime' } + }) + const settings = { activeRuntimeEnvironmentId: 'env-1' } + const base = { projectId: 'repo-1', worktreeId: 'wt-1' } + + await createRuntimeProjectNote(settings, { ...base, title: 'Plan', bodyMarkdown: 'body' }) + await saveRuntimeProjectNote(settings, { + ...base, + note: 'note-1', + title: 'Plan', + bodyMarkdown: 'updated', + revision: 3, + makeActive: true + }) + await renameRuntimeProjectNote(settings, { ...base, note: 'note-1', title: 'Renamed' }) + await deleteRuntimeProjectNote(settings, { ...base, note: 'note-1' }) + await linkRuntimeProjectNote(settings, { ...base, note: 'note-1', kind: 'active' }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'note.create', + params: { + worktree: 'wt-1', + title: 'Plan', + bodyMarkdown: 'body', + makeActive: undefined, + createdBySessionId: undefined + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'note.save', + params: { + worktree: 'wt-1', + note: 'note-1', + title: 'Plan', + bodyMarkdown: 'updated', + revision: 3, + makeActive: true, + updatedBySessionId: undefined + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'note.rename', + params: { + worktree: 'wt-1', + note: 'note-1', + title: 'Renamed', + updatedBySessionId: undefined + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { + selector: 'env-1', + method: 'note.delete', + params: { worktree: 'wt-1', note: 'note-1' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { + selector: 'env-1', + method: 'note.link', + params: { worktree: 'wt-1', note: 'note-1', kind: 'active' }, + timeoutMs: 15_000 + }) + }) + + it('routes panel state through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { state: 'emptyDraft', projectId: 'repo-1', worktreeId: 'wt-1' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await resolveRuntimeNotesPanelState( + { activeRuntimeEnvironmentId: 'env-1' }, + { projectId: 'repo-1', worktreeId: 'wt-1' } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'note.panelState', + params: { worktree: 'wt-1' }, + timeoutMs: 15_000 + }) + }) + + it('returns noProject for remote panel state without a project/worktree', async () => { + await expect( + resolveRuntimeNotesPanelState({ activeRuntimeEnvironmentId: 'env-1' }, {}) + ).resolves.toEqual({ state: 'noProject' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-notes-client.ts b/src/renderer/src/runtime/runtime-notes-client.ts new file mode 100644 index 00000000000..669d9d8681c --- /dev/null +++ b/src/renderer/src/runtime/runtime-notes-client.ts @@ -0,0 +1,229 @@ +import type { GlobalSettings } from '../../../shared/types' +import type { + NoteAppendArgs, + NoteCreateArgs, + NoteDeleteArgs, + NoteDeleteResult, + NoteLink, + NoteLinkArgs, + NoteListArgs, + NoteListResult, + NoteMutationResult, + NoteRenameArgs, + NoteSaveArgs, + NoteSearchArgs, + NoteShowArgs, + NoteShowResult, + NotesPanelOpenState, + NotesPanelStateArgs +} from '../../../shared/notes-types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' + +type RuntimeNotesSettings = Pick | null | undefined + +function requireWorktreeId(worktreeId: string | null | undefined): string { + if (!worktreeId?.trim()) { + throw new Error('Project notes require an active worktree on remote runtime servers.') + } + return worktreeId +} + +function noteTarget(settings: RuntimeNotesSettings): ReturnType { + return getActiveRuntimeTarget(settings) +} + +export async function listRuntimeProjectNotes( + settings: RuntimeNotesSettings, + args: NoteListArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.list(args) + } + return callRuntimeRpc( + target, + 'note.list', + { worktree: requireWorktreeId(args.worktreeId), limit: args.limit }, + { timeoutMs: 15_000 } + ) +} + +export async function showRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteShowArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.show(args) + } + return callRuntimeRpc( + target, + 'note.show', + { worktree: requireWorktreeId(args.worktreeId), note: args.note }, + { timeoutMs: 15_000 } + ) +} + +export async function createRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteCreateArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.create(args) + } + return callRuntimeRpc( + target, + 'note.create', + { + worktree: requireWorktreeId(args.worktreeId), + title: args.title, + bodyMarkdown: args.bodyMarkdown, + makeActive: args.makeActive, + createdBySessionId: args.createdBySessionId + }, + { timeoutMs: 15_000 } + ) +} + +export async function saveRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteSaveArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.save(args) + } + return callRuntimeRpc( + target, + 'note.save', + { + worktree: requireWorktreeId(args.worktreeId), + note: args.note, + title: args.title, + bodyMarkdown: args.bodyMarkdown, + revision: args.revision, + makeActive: args.makeActive, + updatedBySessionId: args.updatedBySessionId + }, + { timeoutMs: 15_000 } + ) +} + +export async function renameRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteRenameArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.rename(args) + } + return callRuntimeRpc( + target, + 'note.rename', + { + worktree: requireWorktreeId(args.worktreeId), + note: args.note, + title: args.title, + updatedBySessionId: args.updatedBySessionId + }, + { timeoutMs: 15_000 } + ) +} + +export async function deleteRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteDeleteArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.delete(args) + } + return callRuntimeRpc( + target, + 'note.delete', + { worktree: requireWorktreeId(args.worktreeId), note: args.note }, + { timeoutMs: 15_000 } + ) +} + +export async function appendRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteAppendArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.append(args) + } + return callRuntimeRpc( + target, + 'note.append', + { + worktree: requireWorktreeId(args.worktreeId), + note: args.note, + bodyMarkdown: args.bodyMarkdown, + makeActive: args.makeActive, + updatedBySessionId: args.updatedBySessionId + }, + { timeoutMs: 15_000 } + ) +} + +export async function searchRuntimeProjectNotes( + settings: RuntimeNotesSettings, + args: NoteSearchArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.search(args) + } + return callRuntimeRpc( + target, + 'note.search', + { + worktree: requireWorktreeId(args.worktreeId), + query: args.query, + limit: args.limit + }, + { timeoutMs: 15_000 } + ) +} + +export async function linkRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteLinkArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.link(args) + } + return callRuntimeRpc( + target, + 'note.link', + { + worktree: requireWorktreeId(args.worktreeId), + note: args.note, + kind: args.kind + }, + { timeoutMs: 15_000 } + ) +} + +export async function resolveRuntimeNotesPanelState( + settings: RuntimeNotesSettings, + args: NotesPanelStateArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.panelState(args) + } + if (!args.projectId && !args.worktreeId) { + return { state: 'noProject' } + } + return callRuntimeRpc( + target, + 'note.panelState', + { worktree: requireWorktreeId(args.worktreeId) }, + { timeoutMs: 15_000 } + ) +} diff --git a/src/renderer/src/runtime/runtime-protocol-compat.ts b/src/renderer/src/runtime/runtime-protocol-compat.ts new file mode 100644 index 00000000000..3459c01b834 --- /dev/null +++ b/src/renderer/src/runtime/runtime-protocol-compat.ts @@ -0,0 +1,19 @@ +import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../../shared/protocol-compat' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' +import type { RuntimeStatus } from '../../../shared/runtime-types' + +export function assertRuntimeStatusCompatible(status: RuntimeStatus): void { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) + if (verdict.kind === 'blocked') { + throw new Error(describeRuntimeCompatBlock(verdict)) + } +} diff --git a/src/renderer/src/runtime/runtime-repo-client.ts b/src/renderer/src/runtime/runtime-repo-client.ts new file mode 100644 index 00000000000..bdd596c5da7 --- /dev/null +++ b/src/renderer/src/runtime/runtime-repo-client.ts @@ -0,0 +1,42 @@ +import type { GlobalSettings } from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' + +export type RuntimeRepoBaseRefDefault = { + defaultBaseRef: string | null + remoteCount: number +} + +export async function getRuntimeRepoBaseRefDefault( + settings: Pick | null | undefined, + repoId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return window.api.repos.getBaseRefDefault({ repoId }) + } + return callRuntimeRpc( + target, + 'repo.baseRefDefault', + { repo: repoId }, + { timeoutMs: 15_000 } + ) +} + +export async function searchRuntimeRepoBaseRefs( + settings: Pick | null | undefined, + repoId: string, + query: string, + limit: number +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return window.api.repos.searchBaseRefs({ repoId, query, limit }) + } + const result = await callRuntimeRpc<{ refs: string[]; truncated: boolean }>( + target, + 'repo.searchRefs', + { repo: repoId, query, limit }, + { timeoutMs: 15_000 } + ) + return result.refs +} diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts new file mode 100644 index 00000000000..21eec50c6c0 --- /dev/null +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + callRuntimeRpc, + clearRuntimeCompatibilityCacheForTests, + getActiveRuntimeTarget, + RuntimeRpcCallError, + unwrapRuntimeRpcResult +} from './runtime-rpc-client' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' + +const runtimeCall = vi.fn() +const runtimeEnvironmentCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeCall.mockReset() + runtimeEnvironmentCall.mockReset() + vi.stubGlobal('window', { + api: { + runtime: { call: runtimeCall }, + runtimeEnvironments: { call: runtimeEnvironmentCall } + } + }) +}) + +describe('runtime RPC client routing', () => { + it('uses the local runtime when no active environment is selected', () => { + expect(getActiveRuntimeTarget(null)).toEqual({ kind: 'local' }) + expect(getActiveRuntimeTarget({ activeRuntimeEnvironmentId: null })).toEqual({ kind: 'local' }) + expect(getActiveRuntimeTarget({ activeRuntimeEnvironmentId: ' ' })).toEqual({ kind: 'local' }) + }) + + it('uses the active saved environment when one is selected', () => { + expect(getActiveRuntimeTarget({ activeRuntimeEnvironmentId: 'env-1' })).toEqual({ + kind: 'environment', + environmentId: 'env-1' + }) + }) + + it('routes local runtime calls through window.api.runtime.call', async () => { + runtimeCall.mockResolvedValue({ + id: 'local', + ok: true, + result: [{ id: 'repo-1' }], + _meta: { runtimeId: 'local-runtime' } + }) + + await expect(callRuntimeRpc({ kind: 'local' }, 'repo.list')).resolves.toEqual([ + { id: 'repo-1' } + ]) + expect(runtimeCall).toHaveBeenCalledWith({ method: 'repo.list', params: undefined }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes remote runtime calls through window.api.runtimeEnvironments.call', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'remote', + ok: true, + result: { graphStatus: 'ready' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + callRuntimeRpc({ kind: 'environment', environmentId: 'env-1' }, 'status.get', undefined, { + timeoutMs: 50 + }) + ).resolves.toEqual({ graphStatus: 'ready' }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'status.get', + params: undefined, + timeoutMs: 50 + }) + expect(runtimeCall).not.toHaveBeenCalled() + }) + + it('preflights remote runtime compatibility before non-status calls', async () => { + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : { repos: [{ id: 'repo-1' }] } + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect( + callRuntimeRpc({ kind: 'environment', environmentId: 'env-compat' }, 'repo.list') + ).resolves.toEqual({ repos: [{ id: 'repo-1' }] }) + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get', + 'repo.list' + ]) + }) + + it('caches successful remote compatibility checks per environment', async () => { + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : { ok: true } + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await callRuntimeRpc({ kind: 'environment', environmentId: 'env-cache' }, 'repo.list') + await callRuntimeRpc({ kind: 'environment', environmentId: 'env-cache' }, 'worktree.list') + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get', + 'repo.list', + 'worktree.list' + ]) + }) + + it('throws structured runtime RPC failures', () => { + const failure = { + id: 'rpc-1', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method: nope' }, + _meta: { runtimeId: 'runtime-1' } + } + + expect(() => unwrapRuntimeRpcResult(failure)).toThrow(RuntimeRpcCallError) + try { + unwrapRuntimeRpcResult(failure) + } catch (error) { + expect(error).toBeInstanceOf(RuntimeRpcCallError) + expect((error as RuntimeRpcCallError).code).toBe('method_not_found') + expect((error as RuntimeRpcCallError).response).toBe(failure) + } + }) +}) diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts new file mode 100644 index 00000000000..38540f694a4 --- /dev/null +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -0,0 +1,108 @@ +import type { GlobalSettings } from '../../../shared/types' +import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' + +export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } + +const compatibleRuntimeEnvironments = new Map>() + +export class RuntimeRpcCallError extends Error { + readonly code: string + readonly response: RuntimeRpcFailure + + constructor(response: RuntimeRpcFailure) { + super(response.error.message) + this.name = 'RuntimeRpcCallError' + this.code = response.error.code + this.response = response + } +} + +export function getActiveRuntimeTarget( + settings: Pick | null | undefined +): RuntimeClientTarget { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + if (!environmentId) { + return { kind: 'local' } + } + return { kind: 'environment', environmentId } +} + +export function settingsForRuntimeOwner( + settings: Pick | null | undefined, + runtimeEnvironmentId: string | null | undefined +): Pick | null | undefined { + const ownerId = runtimeEnvironmentId?.trim() + return ownerId ? { activeRuntimeEnvironmentId: ownerId } : settings +} + +export async function callRuntimeRpc( + target: RuntimeClientTarget, + method: string, + params?: unknown, + options: { timeoutMs?: number } = {} +): Promise { + if (target.kind === 'environment' && method !== 'status.get') { + await ensureRuntimeEnvironmentCompatible(target.environmentId, options.timeoutMs) + } + const response = + target.kind === 'local' + ? await window.api.runtime.call({ method, params }) + : await window.api.runtimeEnvironments.call({ + selector: target.environmentId, + method, + params, + timeoutMs: options.timeoutMs + }) + return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) +} + +async function ensureRuntimeEnvironmentCompatible( + environmentId: string, + timeoutMs?: number +): Promise { + const cached = compatibleRuntimeEnvironments.get(environmentId) + if (cached) { + await cached + return + } + const check = (async () => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'status.get', + timeoutMs + }) + const status = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + assertRuntimeStatusCompatible(status) + })() + compatibleRuntimeEnvironments.set(environmentId, check) + try { + await check + } catch (error) { + compatibleRuntimeEnvironments.delete(environmentId) + throw error + } +} + +export function clearRuntimeCompatibilityCache(environmentId?: string | null): void { + const trimmed = environmentId?.trim() + if (trimmed) { + compatibleRuntimeEnvironments.delete(trimmed) + return + } + compatibleRuntimeEnvironments.clear() +} + +export function clearRuntimeCompatibilityCacheForTests(): void { + clearRuntimeCompatibilityCache() +} + +export function unwrapRuntimeRpcResult(response: RuntimeRpcResponse): TResult { + if (response.ok === false) { + throw new RuntimeRpcCallError(response) + } + return response.result +} diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts new file mode 100644 index 00000000000..222cb9e308b --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { inspectRuntimeTerminalProcess, sendRuntimePtyInput } from './runtime-terminal-inspection' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +describe('runtime terminal owner routing', () => { + const runtimeCall = vi.fn() + const runtimeTransportCall = vi.fn() + const localWrite = vi.fn() + const localForeground = vi.fn() + const localHasChildren = vi.fn() + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + runtimeCall.mockResolvedValue({ + ok: true, + result: { process: { foregroundProcess: 'bash', hasChildProcesses: true } }, + _meta: { runtimeId: 'runtime-1' } + }) + runtimeTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeCall(args) + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeTransportCall }, + pty: { + write: localWrite, + getForegroundProcess: localForeground, + hasChildProcesses: localHasChildren + } + } + }) + }) + + it('sends input through the PTY owning environment instead of the active one', async () => { + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + ).toBe(true) + + await vi.waitFor(() => { + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.send', + params: { terminal: 'terminal-1', text: 'x' }, + timeoutMs: 15_000 + }) + }) + expect(localWrite).not.toHaveBeenCalled() + }) + + it('inspects the PTY owning environment instead of the active one', async () => { + await expect( + inspectRuntimeTerminalProcess( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1' + ) + ).resolves.toEqual({ foregroundProcess: 'bash', hasChildProcesses: true }) + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.inspectProcess', + params: { terminal: 'terminal-1' }, + timeoutMs: 15_000 + }) + expect(localForeground).not.toHaveBeenCalled() + expect(localHasChildren).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts new file mode 100644 index 00000000000..9c5c1f2f73f --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -0,0 +1,62 @@ +import type { GlobalSettings } from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle +} from './runtime-terminal-stream' + +export type RuntimeTerminalProcessInspection = { + foregroundProcess: string | null + hasChildProcesses: boolean +} + +const REMOTE_PTY_ID_PREFIX = 'remote:' + +export function isRemoteRuntimePtyId(ptyId: string): boolean { + return ptyId.startsWith(REMOTE_PTY_ID_PREFIX) +} + +export async function inspectRuntimeTerminalProcess( + settings: Pick | null | undefined, + ptyId: string +): Promise { + const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) + const target = ownerEnvironmentId + ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) + : getActiveRuntimeTarget(settings) + const terminal = getRemoteRuntimeTerminalHandle(ptyId) + if (target.kind !== 'environment' || !terminal) { + const [foregroundProcess, hasChildProcesses] = await Promise.all([ + window.api.pty.getForegroundProcess(ptyId), + window.api.pty.hasChildProcesses(ptyId) + ]) + return { foregroundProcess, hasChildProcesses } + } + + const result = await callRuntimeRpc<{ process: RuntimeTerminalProcessInspection }>( + target, + 'terminal.inspectProcess', + { terminal }, + { timeoutMs: 15_000 } + ) + return result.process +} + +export function sendRuntimePtyInput( + settings: Pick | null | undefined, + ptyId: string, + data: string +): boolean { + const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) + const target = ownerEnvironmentId + ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) + : getActiveRuntimeTarget(settings) + const terminal = getRemoteRuntimeTerminalHandle(ptyId) + if (target.kind !== 'environment' || !terminal) { + window.api.pty.write(ptyId, data) + return true + } + + void callRuntimeRpc(target, 'terminal.send', { terminal, text: data }, { timeoutMs: 15_000 }) + return true +} diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts new file mode 100644 index 00000000000..d08b413af74 --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { resetRemoteRuntimeTerminalMultiplexersForTests } from './remote-runtime-terminal-multiplexer' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle, + subscribeToRuntimeTerminalData, + toRemoteRuntimePtyId +} from './runtime-terminal-stream' + +describe('remote runtime terminal ids', () => { + it('encodes and decodes the owning runtime environment', () => { + const ptyId = toRemoteRuntimePtyId('terminal:one', 'env-1') + + expect(ptyId).toBe('remote:env-1@@terminal%3Aone') + expect(getRemoteRuntimePtyEnvironmentId(ptyId)).toBe('env-1') + expect(getRemoteRuntimeTerminalHandle(ptyId)).toBe('terminal:one') + }) + + it('keeps legacy remote ids readable', () => { + expect(getRemoteRuntimePtyEnvironmentId('remote:terminal-1')).toBeNull() + expect(getRemoteRuntimeTerminalHandle('remote:terminal-1')).toBe('terminal-1') + }) +}) + +describe('remote runtime terminal data subscriptions', () => { + const runtimeSubscribe = vi.fn() + const sendBinary = vi.fn() + const unsubscribe = vi.fn() + let callbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + } | null = null + + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeTerminalMultiplexersForTests() + callbacks = null + runtimeSubscribe.mockImplementation(async (_args: unknown, nextCallbacks: typeof callbacks) => { + callbacks = nextCallbacks + queueMicrotask(() => + callbacks?.onResponse({ + ok: true, + result: { type: 'ready' } + }) + ) + return { unsubscribe, sendBinary } + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + subscribe: runtimeSubscribe + } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses the shared terminal multiplexer for sidecar data watchers', async () => { + const watcher = vi.fn() + + const dispose = await subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-1', + 'watcher-1', + watcher + ) + + expect(runtimeSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex' + }), + expect.any(Object) + ) + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled()) + const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0]) + expect(subscribeFrame?.opcode).toBe(TerminalStreamOpcode.Subscribe) + const subscribePayload = + subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload) + expect(subscribePayload?.streamId).toEqual(expect.any(Number)) + + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: subscribePayload!.streamId, + seq: 1, + payload: encodeTerminalStreamText('live') + }) + ) + + expect(watcher).toHaveBeenCalledWith('live') + dispose() + expect(unsubscribe).toHaveBeenCalled() + }) + + it('rejects remote terminal subscriptions when the multiplex connection fails', async () => { + runtimeSubscribe.mockRejectedValueOnce(new Error('offline')) + + await expect( + subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-1', + 'watcher-1', + vi.fn() + ) + ).rejects.toThrow('offline') + + expect(sendBinary).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.ts b/src/renderer/src/runtime/runtime-terminal-stream.ts new file mode 100644 index 00000000000..d5e747e3344 --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-stream.ts @@ -0,0 +1,163 @@ +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import type { GlobalSettings } from '../../../shared/types' +import { RuntimeRpcCallError, getActiveRuntimeTarget } from './runtime-rpc-client' +import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-multiplexer' + +const REMOTE_PTY_ID_PREFIX = 'remote:' +const REMOTE_PTY_OWNER_SEPARATOR = '@@' + +export type RemoteRuntimePtyIdParts = { + environmentId: string | null + handle: string +} + +export type RuntimeTerminalSubscribeEvent = + | { + type: 'scrollback' | 'subscribed' + streamId?: number | null + lines?: string[] + truncated?: boolean + serialized?: string + cols?: number + rows?: number + } + | { type: 'data'; chunk: string } + | { type: 'end' } + | { type: string; [key: string]: unknown } + +export function toRemoteRuntimePtyId(handle: string, environmentId?: string | null): string { + const owner = environmentId?.trim() + if (!owner) { + return `${REMOTE_PTY_ID_PREFIX}${handle}` + } + return `${REMOTE_PTY_ID_PREFIX}${encodeURIComponent(owner)}${REMOTE_PTY_OWNER_SEPARATOR}${encodeURIComponent(handle)}` +} + +export function parseRemoteRuntimePtyId(ptyId: string): RemoteRuntimePtyIdParts | null { + if (!ptyId.startsWith(REMOTE_PTY_ID_PREFIX)) { + return null + } + const rest = ptyId.slice(REMOTE_PTY_ID_PREFIX.length) + const separatorIndex = rest.indexOf(REMOTE_PTY_OWNER_SEPARATOR) + if (separatorIndex === -1) { + return { environmentId: null, handle: rest } + } + return { + environmentId: decodeURIComponent(rest.slice(0, separatorIndex)), + handle: decodeURIComponent(rest.slice(separatorIndex + REMOTE_PTY_OWNER_SEPARATOR.length)) + } +} + +export function getRemoteRuntimeTerminalHandle(ptyId: string): string | null { + return parseRemoteRuntimePtyId(ptyId)?.handle ?? null +} + +export function getRemoteRuntimePtyEnvironmentId(ptyId: string): string | null { + return parseRemoteRuntimePtyId(ptyId)?.environmentId ?? null +} + +export function isRuntimeTerminalScrollbackEvent( + event: RuntimeTerminalSubscribeEvent +): event is Extract { + return event.type === 'scrollback' || event.type === 'subscribed' +} + +export function isRuntimeTerminalDataEvent( + event: RuntimeTerminalSubscribeEvent +): event is Extract { + return event.type === 'data' && typeof (event as { chunk?: unknown }).chunk === 'string' +} + +export function runtimeTerminalErrorMessage(error: unknown): string { + if (error instanceof RuntimeRpcCallError) { + return error.message + } + return error instanceof Error ? error.message : String(error) +} + +export function readRuntimeTerminalScrollback(event: { + serialized?: string + lines?: string[] +}): string | null { + if (event.serialized) { + return event.serialized + } + if (event.lines && event.lines.length > 0) { + return `${event.lines.join('\r\n')}\r\n` + } + return null +} + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.byteLength + } + return out +} + +export function createRuntimeTerminalBinaryReader(callbacks: { + onData: (data: string) => void + onSnapshot: (data: string) => void + onEnd?: () => void +}): (bytes: Uint8Array) => void { + let snapshotChunks: Uint8Array[] = [] + + return (bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + if (!frame) { + return + } + if (frame.opcode === TerminalStreamOpcode.Output) { + callbacks.onData(decodeTerminalStreamText(frame.payload)) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { + snapshotChunks = [] + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) { + snapshotChunks.push(frame.payload) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) { + callbacks.onSnapshot(decodeTerminalStreamText(concatBytes(snapshotChunks))) + snapshotChunks = [] + callbacks.onEnd?.() + } + } +} + +export async function subscribeToRuntimeTerminalData( + settings: Pick | null | undefined, + ptyId: string, + clientId: string, + watcher: (data: string) => void +): Promise<() => void> { + const terminal = getRemoteRuntimeTerminalHandle(ptyId) + const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) + const target = ownerEnvironmentId + ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) + : getActiveRuntimeTarget(settings) + if (target.kind !== 'environment' || !terminal) { + return () => {} + } + + const stream = await getRemoteRuntimeTerminalMultiplexer(target.environmentId).subscribeTerminal({ + terminal, + client: { id: clientId, type: 'desktop' }, + callbacks: { + onData: watcher, + onSnapshot: watcher + } + }) + + return () => stream.close() +} diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index f29297d19a7..3b22bb062c7 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -1,867 +1,415 @@ -/* eslint-disable max-lines -- - * Why: this slice test keeps closely related browser-slice scenarios - * (create/close, reopen, hydrate, shutdown) in one file so the shared - * webview-registry mock setup stays consistent across behaviors. - */ +/* eslint-disable max-lines -- Why: browser slice behavior shares one mocked store harness; splitting only the tests would duplicate more setup than it saves. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { createBrowserSlice } from './browser' +import type { AppState } from '../types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -vi.mock('../../components/browser-pane/webview-registry', () => ({ - destroyPersistentWebview: vi.fn() -})) +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() -import { createTestStore, makeTabGroup, makeWorktree, seedStore } from './store-test-helpers' -import { destroyPersistentWebview } from '../../components/browser-pane/webview-registry' +const mockApi = { + browser: { + sessionListProfiles: vi.fn().mockResolvedValue([]), + sessionCreateProfile: vi.fn().mockResolvedValue(null), + sessionDeleteProfile: vi.fn().mockResolvedValue(false), + sessionImportCookies: vi.fn().mockResolvedValue({ ok: false, reason: 'canceled' }), + sessionDetectBrowsers: vi.fn().mockResolvedValue([]), + sessionImportFromBrowser: vi.fn().mockResolvedValue({ ok: false, reason: 'canceled' }), + sessionClearDefaultCookies: vi.fn().mockResolvedValue(false), + notifyActiveTabChanged: vi.fn().mockResolvedValue(undefined) + }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall + } +} -describe('browser slice', () => { - it('places a new tab in the target group when targetGroupId is provided', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'terminal', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [ - makeTabGroup({ id: 'terminal-group', worktreeId, activeTabId: null, tabOrder: [] }), - makeTabGroup({ id: 'browser-group', worktreeId, activeTabId: null, tabOrder: [] }) - ] - }, - activeGroupIdByWorktree: { [worktreeId]: 'terminal-group' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) +// @ts-expect-error test window mock +globalThis.window = { api: mockApi } - const created = store.getState().createBrowserTab(worktreeId, 'https://example.com', { - title: 'Example', - targetGroupId: 'browser-group' - }) +function createTestStore() { + return create()( + (...a) => + ({ + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + tabsByWorktree: {}, + openFiles: [], + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, + worktreesByRepo: {}, + createUnifiedTab: vi.fn(), + closeUnifiedTab: vi.fn(), + activateTab: vi.fn(), + setTabLabel: vi.fn(), + ...createBrowserSlice(...a) + }) as unknown as AppState + ) +} - const unifiedTab = (store.getState().unifiedTabsByWorktree[worktreeId] ?? []).find( - (t) => t.contentType === 'browser' && t.entityId === created.id - ) - expect(unifiedTab?.groupId).toBe('browser-group') - }) +function settingsWithRuntime(id: string): AppState['settings'] { + return { activeRuntimeEnvironmentId: id } as AppState['settings'] +} - it('falls back to active group when targetGroupId is not provided', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'terminal', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [ - makeTabGroup({ id: 'terminal-group', worktreeId, activeTabId: null, tabOrder: [] }), - makeTabGroup({ id: 'browser-group', worktreeId, activeTabId: null, tabOrder: [] }) - ] - }, - activeGroupIdByWorktree: { [worktreeId]: 'terminal-group' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const created = store.getState().createBrowserTab(worktreeId, 'https://example.com', { - title: 'Example' - }) - - const unifiedTab = (store.getState().unifiedTabsByWorktree[worktreeId] ?? []).find( - (t) => t.contentType === 'browser' && t.entityId === created.id - ) - expect(unifiedTab?.groupId).toBe('terminal-group') - }) - - it('reopens the most recently closed browser tab in the same worktree', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [ - makeWorktree({ - id: worktreeId, - repoId: 'repo1', - path: '/tmp/wt-1' - }) - ] - }, - groupsByWorktree: { - [worktreeId]: [ - makeTabGroup({ - id: 'group-1', - worktreeId, - activeTabId: null, - tabOrder: [] - }) - ] - }, - activeGroupIdByWorktree: { - [worktreeId]: 'group-1' - }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const created = store.getState().createBrowserTab(worktreeId, 'https://example.com/docs', { - title: 'Docs' - }) - store.getState().closeBrowserTab(created.id) - - expect(store.getState().browserTabsByWorktree[worktreeId]).toBeUndefined() - expect(store.getState().recentlyClosedBrowserTabsByWorktree[worktreeId]).toHaveLength(1) - - const reopened = store.getState().reopenClosedBrowserTab(worktreeId) - - expect(reopened).not.toBeNull() - expect(reopened?.id).not.toBe(created.id) - expect(reopened?.url).toBe('https://example.com/docs') - expect(reopened?.title).toBe('Docs') - expect(store.getState().browserTabsByWorktree[worktreeId]).toHaveLength(1) - expect(store.getState().recentlyClosedBrowserTabsByWorktree[worktreeId]).toHaveLength(0) - }) - - it('reopens a multi-page workspace without duplicating the active URL (page order ≠ active first)', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [ - makeWorktree({ - id: worktreeId, - repoId: 'repo1', - path: '/tmp/wt-1' - }) - ] - }, - groupsByWorktree: { - [worktreeId]: [ - makeTabGroup({ - id: 'group-1', - worktreeId, - activeTabId: null, - tabOrder: [] - }) - ] - }, - activeGroupIdByWorktree: { - [worktreeId]: 'group-1' - }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const ws = store.getState().createBrowserTab(worktreeId, 'https://example.com/a', { - title: 'A' - }) - store - .getState() - .createBrowserPage(ws.id, 'https://example.com/b', { title: 'B', activate: true }) - const beforeClose = store.getState().browserPagesByWorkspace[ws.id] ?? [] - expect(beforeClose).toHaveLength(2) - expect(store.getState().browserTabsByWorktree[worktreeId]?.[0]?.url).toBe( - 'https://example.com/b' - ) - - store.getState().closeBrowserTab(ws.id) - const reopened = store.getState().reopenClosedBrowserTab(worktreeId) - expect(reopened).not.toBeNull() - const pages = store.getState().browserPagesByWorkspace[reopened!.id] ?? [] - expect(pages).toHaveLength(2) - const urls = new Set(pages.map((p) => p.url)) - expect(urls.has('https://example.com/a')).toBe(true) - expect(urls.has('https://example.com/b')).toBe(true) - expect(store.getState().browserTabsByWorktree[worktreeId]?.[0]?.url).toBe( - 'https://example.com/b' - ) - }) - - it('sets pending address-bar focus when focusAddressBar is true even for non-blank URLs', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'terminal', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [makeTabGroup({ id: 'group-1', worktreeId, activeTabId: null, tabOrder: [] })] - }, - activeGroupIdByWorktree: { [worktreeId]: 'group-1' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const created = store.getState().createBrowserTab(worktreeId, 'https://example.com/home', { - title: 'Home', - focusAddressBar: true - }) - - const pageId = store.getState().browserPagesByWorkspace[created.id]?.[0]?.id - expect(pageId).toBeDefined() - expect(store.getState().pendingAddressBarFocusByTabId[created.id]).toBe(true) - expect(store.getState().pendingAddressBarFocusByPageId[pageId!]).toBe(true) - }) - - it('does not set pending address-bar focus for non-blank URLs when focusAddressBar is not set', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'terminal', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [makeTabGroup({ id: 'group-1', worktreeId, activeTabId: null, tabOrder: [] })] - }, - activeGroupIdByWorktree: { [worktreeId]: 'group-1' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const created = store.getState().createBrowserTab(worktreeId, 'https://example.com/home', { - title: 'Home' - }) - - expect(store.getState().pendingAddressBarFocusByTabId[created.id]).toBeUndefined() - }) -}) - -describe('hydrateBrowserSession', () => { +describe('createBrowserSlice runtime guard', () => { beforeEach(() => { - vi.mocked(destroyPersistentWebview).mockClear() + vi.clearAllMocks() + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', ok: true, result: {} }) }) - // Why: design §3. hydrateBrowserSession drops workspaces whose worktree no - // longer exists. Reducers are pure; destroy calls must bracket the set() - // to mirror closeBrowserTab's contract and keep future callers (that might - // run hydrate after webviews are live) safe without surgery. - it('destroys webviews for dropped workspaces before committing state', () => { + it('fetches browser profiles from the active runtime environment', async () => { const store = createTestStore() - const survivingWorktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - worktreesByRepo: { - repo1: [makeWorktree({ id: survivingWorktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - // Why: destroyWorkspaceWebviews reads pre-hydrate browserPagesByWorkspace - // to resolve page ids. If the store is empty at hydrate time, the helper - // falls back to the legacy workspace-id destroy (design §2 fallback). To - // prove the page-id branch runs when pages exist in-store, seed them - // first. Today's boot-only caller hits the fallback because hydrate is - // the first thing to populate the map — the assertion here covers a - // future re-hydrate after webviews are already live. - browserPagesByWorkspace: { - 'workspace-drop': [ + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + profiles: [ { - id: 'page-drop-a', - workspaceId: 'workspace-drop', - worktreeId: 'repo1::/tmp/wt-gone', - url: 'about:blank', - title: 'a', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - }, - { - id: 'page-drop-b', - workspaceId: 'workspace-drop', - worktreeId: 'repo1::/tmp/wt-gone', - url: 'about:blank', - title: 'b', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 + id: 'default', + scope: 'default', + partition: 'persist:orca-default', + label: 'Default', + source: null } ] - } as never + }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: settingsWithRuntime('env-1'), + browserSessionProfiles: [] }) - store.getState().hydrateBrowserSession({ - browserTabsByWorktree: { - [survivingWorktreeId]: [ - { - id: 'workspace-keep', - worktreeId: survivingWorktreeId, - label: 'keep', - sessionProfileId: null, - pageIds: ['page-keep'], - activePageId: 'page-keep', - url: 'about:blank', - title: 'keep', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ], - 'repo1::/tmp/wt-gone': [ - { - id: 'workspace-drop', - worktreeId: 'repo1::/tmp/wt-gone', - label: 'drop', - sessionProfileId: null, - pageIds: ['page-drop-a', 'page-drop-b'], - activePageId: 'page-drop-a', - url: 'about:blank', - title: 'drop', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - browserPagesByWorkspace: { - 'workspace-drop': [ - { - id: 'page-drop-a', - workspaceId: 'workspace-drop', - worktreeId: 'repo1::/tmp/wt-gone', - url: 'about:blank', - title: 'a', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - }, - { - id: 'page-drop-b', - workspaceId: 'workspace-drop', - worktreeId: 'repo1::/tmp/wt-gone', - url: 'about:blank', - title: 'b', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ], - 'workspace-keep': [ - { - id: 'page-keep', - workspaceId: 'workspace-keep', - worktreeId: survivingWorktreeId, - url: 'about:blank', - title: 'keep', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - } - } as never) + await store.getState().fetchBrowserSessionProfiles() - expect(destroyPersistentWebview).toHaveBeenCalledWith('page-drop-a') - expect(destroyPersistentWebview).toHaveBeenCalledWith('page-drop-b') - expect(destroyPersistentWebview).not.toHaveBeenCalledWith('page-keep') - expect(store.getState().browserTabsByWorktree['repo1::/tmp/wt-gone']).toBeUndefined() - expect(store.getState().browserPagesByWorkspace['workspace-drop']).toBeUndefined() - expect(store.getState().browserTabsByWorktree[survivingWorktreeId]).toHaveLength(1) - }) - - it('redacts Kagi session tokens from hydrated browser history', () => { - const store = createTestStore() - - store.getState().hydrateBrowserSession({ - browserUrlHistory: [ - { - url: 'https://kagi.com/search?token=secret&q=hello+world', - normalizedUrl: 'https://kagi.com/search?token=secret&q=hello+world', - title: 'Kagi Search', - lastVisitedAt: 1, - visitCount: 1 - } - ] - } as never) - - expect(store.getState().browserUrlHistory).toEqual([ + expect(mockApi.browser.sessionListProfiles).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'browser.profileList', + params: undefined, + timeoutMs: 15_000 + }) + expect(store.getState().browserSessionProfiles).toEqual([ { - url: 'https://kagi.com/search?q=hello+world', - normalizedUrl: 'https://kagi.com/search?q=hello+world', - title: 'Kagi Search', - lastVisitedAt: 1, - visitCount: 1 + id: 'default', + scope: 'default', + partition: 'persist:orca-default', + label: 'Default', + source: null } ]) }) -}) -// Why: setBrowserPageUrl is the single sink for URL updates from did-navigate, -// the agent-browser CDP nav-update IPC, and direct address-bar submits. Pin -// redaction at this boundary so a Kagi bearer token cannot reach the -// persisted BrowserPage.url field via any caller that forgot to redact. -describe('setBrowserPageUrl redaction', () => { - it('redacts Kagi session tokens before storing the page URL', () => { + it('does not import local browser cookies while a runtime environment is active', async () => { const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [makeTabGroup({ id: 'group-1', worktreeId, activeTabId: null, tabOrder: [] })] - }, - activeGroupIdByWorktree: { [worktreeId]: 'group-1' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} + store.setState({ settings: settingsWithRuntime('env-1') }) + + const result = await store.getState().importCookiesToProfile('default') + + expect(mockApi.browser.sessionImportCookies).not.toHaveBeenCalled() + expect(result.ok).toBe(false) + expect(store.getState().browserSessionImportState).toMatchObject({ + profileId: 'default', + status: 'error' }) - - const ws = store.getState().createBrowserTab(worktreeId, 'about:blank', { title: 'New Tab' }) - const pageId = store.getState().browserPagesByWorkspace[ws.id]?.[0]?.id - expect(pageId).toBeDefined() - - store - .getState() - .setBrowserPageUrl(pageId!, 'https://kagi.com/search?token=secret&q=hello+world') - - const page = store.getState().browserPagesByWorkspace[ws.id]?.[0] - expect(page?.url).toBe('https://kagi.com/search?q=hello+world') - expect(store.getState().browserTabsByWorktree[worktreeId]?.[0]?.url).toBe( - 'https://kagi.com/search?q=hello+world' - ) - }) -}) - -// Why: the leak this PR fixes lives inside the thunk itself. removeWorktree -// and runSleepWorktree tests stub this thunk, so destroy-call regressions in -// its body would be invisible there. These tests pin the thunk directly. -describe('shutdownWorktreeBrowsers', () => { - beforeEach(() => { - vi.mocked(destroyPersistentWebview).mockClear() }) - function seedWorktree(store: ReturnType, worktreeId: string): void { - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [makeTabGroup({ id: 'group-1', worktreeId, activeTabId: null, tabOrder: [] })] - }, - activeGroupIdByWorktree: { [worktreeId]: 'group-1' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - } - - it('destroys every page in every workspace of the target worktree', async () => { + it('uses local browser IPC when no runtime environment is active', async () => { const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedWorktree(store, worktreeId) + mockApi.browser.sessionListProfiles.mockResolvedValueOnce([ + { + id: 'default', + scope: 'default', + partition: 'persist:orca-default', + label: 'Default', + source: null + } + ]) - const ws1 = store.getState().createBrowserTab(worktreeId, 'https://example.com/a', { - title: 'A' - }) - const ws2 = store.getState().createBrowserTab(worktreeId, 'https://example.com/b', { - title: 'B' - }) - const ws1PageIds = (store.getState().browserPagesByWorkspace[ws1.id] ?? []).map((p) => p.id) - const ws2PageIds = (store.getState().browserPagesByWorkspace[ws2.id] ?? []).map((p) => p.id) - expect(ws1PageIds).toHaveLength(1) - expect(ws2PageIds).toHaveLength(1) + await store.getState().fetchBrowserSessionProfiles() - await store.getState().shutdownWorktreeBrowsers(worktreeId) - - // Why: this is the regression guard. If the thunk ever stops calling - // destroyWorkspaceWebviews (or reverts to the old workspace-id keying), - // this assertion fails loudly. - for (const pageId of [...ws1PageIds, ...ws2PageIds]) { - expect(destroyPersistentWebview).toHaveBeenCalledWith(pageId) - } - expect(store.getState().browserTabsByWorktree[worktreeId]).toBeUndefined() - expect(store.getState().browserPagesByWorkspace[ws1.id]).toBeUndefined() - expect(store.getState().browserPagesByWorkspace[ws2.id]).toBeUndefined() + expect(mockApi.browser.sessionListProfiles).toHaveBeenCalledTimes(1) + expect(store.getState().browserSessionProfiles).toEqual([ + { + id: 'default', + scope: 'default', + partition: 'persist:orca-default', + label: 'Default', + source: null + } + ]) }) - it('destroys every page id of a multi-page workspace', async () => { + it('does not notify the local browser manager when selecting tabs under runtime', () => { const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedWorktree(store, worktreeId) - - const ws = store.getState().createBrowserTab(worktreeId, 'https://example.com/a', { - title: 'A' + store.setState({ + settings: settingsWithRuntime('env-1'), + unifiedTabsByWorktree: {}, + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'about:blank', + title: 'New Tab', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } }) - store - .getState() - .createBrowserPage(ws.id, 'https://example.com/b', { title: 'B', activate: false }) - store - .getState() - .createBrowserPage(ws.id, 'https://example.com/c', { title: 'C', activate: false }) - const pageIds = (store.getState().browserPagesByWorkspace[ws.id] ?? []).map((p) => p.id) - expect(pageIds).toHaveLength(3) - await store.getState().shutdownWorktreeBrowsers(worktreeId) + store.getState().setActiveBrowserTab('workspace-1') - for (const pageId of pageIds) { - expect(destroyPersistentWebview).toHaveBeenCalledWith(pageId) - } + expect(mockApi.browser.notifyActiveTabChanged).not.toHaveBeenCalled() }) - it('leaves other worktrees untouched', async () => { + it('closes the mapped remote tab when closing a browser page in the active runtime', async () => { const store = createTestStore() - const targetWorktreeId = 'repo1::/tmp/wt-1' - const otherWorktreeId = 'repo1::/tmp/wt-2' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: targetWorktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [ - makeWorktree({ id: targetWorktreeId, repoId: 'repo1', path: '/tmp/wt-1' }), - makeWorktree({ id: otherWorktreeId, repoId: 'repo1', path: '/tmp/wt-2' }) + store.setState({ + settings: settingsWithRuntime('env-1'), + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } ] }, - groupsByWorktree: { - [targetWorktreeId]: [ - makeTabGroup({ - id: 'group-1', - worktreeId: targetWorktreeId, - activeTabId: null, - tabOrder: [] - }) - ], - [otherWorktreeId]: [ - makeTabGroup({ - id: 'group-2', - worktreeId: otherWorktreeId, - activeTabId: null, - tabOrder: [] - }) + browserPagesByWorkspace: { + 'workspace-1': [ + { + id: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } ] }, - activeGroupIdByWorktree: { - [targetWorktreeId]: 'group-1', - [otherWorktreeId]: 'group-2' - }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } }) - const targetWs = store.getState().createBrowserTab(targetWorktreeId, 'https://example.com/a', { - title: 'A' - }) - const otherWs = store.getState().createBrowserTab(otherWorktreeId, 'https://example.com/b', { - title: 'B' - }) - const otherPageIds = (store.getState().browserPagesByWorkspace[otherWs.id] ?? []).map( - (p) => p.id - ) + store.getState().closeBrowserPage('page-1') - await store.getState().shutdownWorktreeBrowsers(targetWorktreeId) - - expect(store.getState().browserTabsByWorktree[targetWorktreeId]).toBeUndefined() - expect(store.getState().browserPagesByWorkspace[targetWs.id]).toBeUndefined() - expect(store.getState().browserTabsByWorktree[otherWorktreeId]).toHaveLength(1) - expect(store.getState().browserPagesByWorkspace[otherWs.id]).toHaveLength(1) - for (const pageId of otherPageIds) { - expect(destroyPersistentWebview).not.toHaveBeenCalledWith(pageId) - } - }) - - it('clears activeBrowserTabId and activeTabType when the target is the active worktree', async () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedWorktree(store, worktreeId) - - const ws = store.getState().createBrowserTab(worktreeId, 'https://example.com/a', { - title: 'A', - activate: true - }) - expect(store.getState().activeBrowserTabId).toBe(ws.id) - - await store.getState().shutdownWorktreeBrowsers(worktreeId) - - expect(store.getState().activeBrowserTabId).toBeNull() - expect(store.getState().activeTabType).toBe('terminal') - expect(store.getState().activeBrowserTabIdByWorktree[worktreeId]).toBeUndefined() - }) - - it('leaves global active browser alone when shutting down a background worktree', async () => { - const store = createTestStore() - const activeWorktreeId = 'repo1::/tmp/wt-1' - const backgroundWorktreeId = 'repo1::/tmp/wt-2' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [ - makeWorktree({ id: activeWorktreeId, repoId: 'repo1', path: '/tmp/wt-1' }), - makeWorktree({ id: backgroundWorktreeId, repoId: 'repo1', path: '/tmp/wt-2' }) - ] - }, - groupsByWorktree: { - [activeWorktreeId]: [ - makeTabGroup({ - id: 'group-1', - worktreeId: activeWorktreeId, - activeTabId: null, - tabOrder: [] - }) - ], - [backgroundWorktreeId]: [ - makeTabGroup({ - id: 'group-2', - worktreeId: backgroundWorktreeId, - activeTabId: null, - tabOrder: [] - }) - ] - }, - activeGroupIdByWorktree: { - [activeWorktreeId]: 'group-1', - [backgroundWorktreeId]: 'group-2' - }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const activeWs = store - .getState() - .createBrowserTab(activeWorktreeId, 'https://example.com/active', { - title: 'Active', - activate: true + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'browser.tabClose', + params: { worktree: 'id:wt-1', page: 'remote-page-1' }, + timeoutMs: 15_000 }) - store.getState().createBrowserTab(backgroundWorktreeId, 'https://example.com/bg', { - title: 'Background', - // Why: createBrowserTab defaults to activate=true which would replace - // activeBrowserTabId globally. This test needs the original active tab - // to stay active so we can prove shutdown of the background worktree - // doesn't disturb it. - activate: false }) - expect(store.getState().activeBrowserTabId).toBe(activeWs.id) - - await store.getState().shutdownWorktreeBrowsers(backgroundWorktreeId) - - // Why: §1 shouldResetGlobalBrowser is gated on activeWorktreeId===target. - // Shutting down a background worktree must not disturb what the user is - // currently looking at. - expect(store.getState().activeBrowserTabId).toBe(activeWs.id) - expect(store.getState().activeTabType).toBe('browser') + expect(store.getState().remoteBrowserPageHandlesByPageId['page-1']).toBeUndefined() }) - it('is a no-op when the worktree has no browser tabs', async () => { + it('closes mapped remote tabs when closing a browser workspace in the active runtime', async () => { const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedWorktree(store, worktreeId) - // Override: this test needs activeTabType to start as terminal so we can - // prove shutdown doesn't flip it. - store.setState({ activeTabType: 'terminal' }) - - await store.getState().shutdownWorktreeBrowsers(worktreeId) - - expect(destroyPersistentWebview).not.toHaveBeenCalled() - expect(store.getState().activeTabType).toBe('terminal') - }) -}) - -// Why: focusBrowserTabInWorktree is the renderer side of `tab switch --focus`. -// The defining invariant is no cross-worktree screen theft: when multiple -// agents drive browsers in parallel worktrees, an agent focusing a tab in -// worktree X must never yank the user's view away from worktree Y. -describe('focusBrowserTabInWorktree', () => { - function seedTwoWorktrees( - store: ReturnType, - activeWt: string, - otherWt: string - ): void { - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: activeWt, - activeTabType: 'terminal', - worktreesByRepo: { - repo1: [ - makeWorktree({ id: activeWt, repoId: 'repo1', path: '/tmp/wt-active' }), - makeWorktree({ id: otherWt, repoId: 'repo1', path: '/tmp/wt-other' }) + store.setState({ + settings: settingsWithRuntime('env-1'), + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } ] }, - groupsByWorktree: { - [activeWt]: [ - makeTabGroup({ id: 'g-active', worktreeId: activeWt, activeTabId: null, tabOrder: [] }) - ], - [otherWt]: [ - makeTabGroup({ id: 'g-other', worktreeId: otherWt, activeTabId: null, tabOrder: [] }) + activeBrowserTabId: 'workspace-1', + activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' }, + browserPagesByWorkspace: { + 'workspace-1': [ + { + id: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } ] }, - activeGroupIdByWorktree: { [activeWt]: 'g-active', [otherWt]: 'g-other' }, - activeTabTypeByWorktree: { [activeWt]: 'terminal', [otherWt]: 'terminal' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } }) - } - it('does not yank activeWorktreeId when focusing a tab in a non-active worktree', () => { - const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) + store.getState().closeBrowserTab('workspace-1') - const ws = store.getState().createBrowserTab(otherWt, 'https://example.com/a', { title: 'A' }) - const pageId = (store.getState().browserPagesByWorkspace[ws.id] ?? [])[0]?.id - expect(pageId).toBeDefined() - // createBrowserTab on a non-active worktree leaves activeWorktreeId alone; - // re-pin it here in case any future change to that path regresses it. - store.setState({ activeWorktreeId: activeWt, activeTabType: 'terminal' }) - - store.getState().focusBrowserTabInWorktree(otherWt, pageId!, { surfacePane: true }) - - expect(store.getState().activeWorktreeId).toBe(activeWt) - expect(store.getState().activeTabType).toBe('terminal') + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'browser.tabClose', + params: { worktree: 'id:wt-1', page: 'remote-page-1' }, + timeoutMs: 15_000 + }) + }) + expect(store.getState().remoteBrowserPageHandlesByPageId['page-1']).toBeUndefined() }) - it('updates per-worktree active tab and tab type for the targeted worktree even when not active', () => { + it('closes mapped remote pages in their owning environment after switching local', async () => { const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) + store.setState({ + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + browserPagesByWorkspace: { + 'workspace-1': [ + { + id: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } + }) - const ws = store.getState().createBrowserTab(otherWt, 'https://example.com/a', { title: 'A' }) - const pageId = (store.getState().browserPagesByWorkspace[ws.id] ?? [])[0]?.id - store.setState({ activeWorktreeId: activeWt, activeTabType: 'terminal' }) + store.getState().closeBrowserPage('page-1') - store.getState().focusBrowserTabInWorktree(otherWt, pageId!, { surfacePane: true }) - - // Per-worktree slots: pre-staged for whenever the user next visits otherWt. - expect(store.getState().activeBrowserTabIdByWorktree[otherWt]).toBe(ws.id) - expect(store.getState().activeTabTypeByWorktree[otherWt]).toBe('browser') + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'browser.tabClose', + params: { worktree: 'id:wt-1', page: 'remote-page-1' }, + timeoutMs: 15_000 + }) + }) }) - it('flips global activeBrowserTabId and activeTabType when targeting the active worktree with surfacePane', () => { + it('closes mapped remote tabs in their owning environment after switching environments', async () => { const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) + store.setState({ + settings: settingsWithRuntime('env-2'), + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + browserPagesByWorkspace: { + 'workspace-1': [ + { + id: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } + }) - const ws = store.getState().createBrowserTab(activeWt, 'https://example.com/a', { title: 'A' }) - const pageId = (store.getState().browserPagesByWorkspace[ws.id] ?? [])[0]?.id - // Reset to terminal so we can prove --focus surfaces the pane. - store.setState((s) => ({ - activeTabType: 'terminal', - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [activeWt]: 'terminal' } - })) + store.getState().closeBrowserTab('workspace-1') - store.getState().focusBrowserTabInWorktree(activeWt, pageId!, { surfacePane: true }) - - expect(store.getState().activeBrowserTabId).toBe(ws.id) - expect(store.getState().activeTabType).toBe('browser') - }) - - it('without surfacePane, leaves activeTabType alone even on the active worktree', () => { - const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) - - const ws = store.getState().createBrowserTab(activeWt, 'https://example.com/a', { title: 'A' }) - const pageId = (store.getState().browserPagesByWorkspace[ws.id] ?? [])[0]?.id - // Reset both globals and per-worktree slot so we can prove surfacePane=false - // does NOT flip the user back into the browser pane (createBrowserTab - // itself flips both because it activates the new tab). - store.setState((s) => ({ - activeTabType: 'terminal', - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [activeWt]: 'terminal' } - })) - - store.getState().focusBrowserTabInWorktree(activeWt, pageId!, { surfacePane: false }) - - // Per-worktree active tab still pre-staged, but the user is still on terminal. - expect(store.getState().activeBrowserTabIdByWorktree[activeWt]).toBe(ws.id) - expect(store.getState().activeTabType).toBe('terminal') - expect(store.getState().activeTabTypeByWorktree[activeWt]).toBe('terminal') - }) - - it('finds the owning workspace via pageIds for multi-page workspaces', () => { - const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) - - const ws = store.getState().createBrowserTab(otherWt, 'https://example.com/a', { title: 'A' }) - const pageB = store - .getState() - .createBrowserPage(ws.id, 'https://example.com/b', { title: 'B', activate: false }) - expect(pageB).not.toBeNull() - - store.getState().focusBrowserTabInWorktree(otherWt, pageB!.id, { surfacePane: true }) - - // The workspace's activePageId must follow the focused page id, not its - // first page. Renderer otherwise shows the wrong tab on next visit. - const updatedWs = store.getState().browserTabsByWorktree[otherWt]?.find((t) => t.id === ws.id) - expect(updatedWs?.activePageId).toBe(pageB!.id) - expect(store.getState().activeBrowserTabIdByWorktree[otherWt]).toBe(ws.id) - }) - - it('is a no-op when the page id is not present in the targeted worktree', () => { - const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) - - const before = store.getState() - before.focusBrowserTabInWorktree(otherWt, 'page-that-does-not-exist', { surfacePane: true }) - const after = store.getState() - - // Nothing globally or per-worktree should have moved. Best-effort: the - // page may have closed between the bridge switching and the IPC arriving. - expect(after.activeWorktreeId).toBe(before.activeWorktreeId) - expect(after.activeTabType).toBe(before.activeTabType) - expect(after.activeBrowserTabIdByWorktree[otherWt]).toBeUndefined() - }) - - it('surfaces the pane by default when no options are passed (active worktree)', () => { - const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) - - const ws = store.getState().createBrowserTab(activeWt, 'https://example.com/a', { title: 'A' }) - const pageId = (store.getState().browserPagesByWorkspace[ws.id] ?? [])[0]?.id - store.setState((s) => ({ - activeTabType: 'terminal', - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [activeWt]: 'terminal' } - })) - - store.getState().focusBrowserTabInWorktree(activeWt, pageId!) - - expect(store.getState().activeBrowserTabId).toBe(ws.id) - expect(store.getState().activeTabType).toBe('browser') - expect(store.getState().activeTabTypeByWorktree[activeWt]).toBe('browser') + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'browser.tabClose', + params: { worktree: 'id:wt-1', page: 'remote-page-1' }, + timeoutMs: 15_000 + }) + }) }) }) diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index d20473c299b..e326b761568 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -21,6 +21,19 @@ import { } from '../../../../shared/workspace-session-browser-history' import { pickNeighbor } from './tab-group-state' import { destroyWorkspaceWebviews } from './browser-webview-cleanup' +import { + callRuntimeRpc, + getActiveRuntimeTarget, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' +import type { + BrowserDetectProfilesResult, + BrowserProfileClearDefaultCookiesResult, + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileImportFromBrowserResult, + BrowserProfileListResult +} from '../../../../shared/runtime-types' type CreateBrowserTabOptions = { activate?: boolean @@ -57,9 +70,15 @@ type ClosedBrowserWorkspaceSnapshot = { pages: BrowserPage[] } +export type RemoteBrowserPageHandle = { + environmentId: string + remotePageId: string +} + export type BrowserSlice = { browserTabsByWorktree: Record browserPagesByWorkspace: Record + remoteBrowserPageHandlesByPageId: Record activeBrowserTabId: string | null activeBrowserTabIdByWorktree: Record recentlyClosedBrowserTabsByWorktree: Record @@ -101,6 +120,11 @@ export type BrowserSlice = { updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void setBrowserTabUrl: (pageId: string, url: string) => void setBrowserPageUrl: (pageId: string, url: string) => void + setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void + removeRemoteBrowserPageHandle: ( + pageId: string, + remotePageId?: string + ) => RemoteBrowserPageHandle | null setBrowserPageViewportPreset: ( pageId: string, viewportPresetId: BrowserViewportPresetId | null @@ -172,6 +196,23 @@ function normalizeBrowserTitle(title: string | null | undefined, url: string): s return title } +function isRuntimeEnvironmentActive(state: AppState): boolean { + return Boolean(state.settings?.activeRuntimeEnvironmentId?.trim()) +} + +function closeRemoteBrowserPageInOwningEnvironment( + worktreeId: string, + handle: RemoteBrowserPageHandle +): void { + const target: RuntimeClientTarget = { kind: 'environment', environmentId: handle.environmentId } + void callRuntimeRpc( + target, + 'browser.tabClose', + { worktree: `id:${worktreeId}`, page: handle.remotePageId }, + { timeoutMs: 15_000 } + ).catch(() => {}) +} + function buildBrowserPage( workspaceId: string, worktreeId: string, @@ -297,6 +338,7 @@ function findPage( export const createBrowserSlice: StateCreator = (set, get) => ({ browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + remoteBrowserPageHandlesByPageId: {}, activeBrowserTabId: null, activeBrowserTabIdByWorktree: {}, recentlyClosedBrowserTabsByWorktree: {}, @@ -414,6 +456,7 @@ export const createBrowserSlice: StateCreator = }, closeBrowserTab: (tabId) => { + let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] set((s) => { let owningWorktreeId: string | null = null let closedWorkspace: BrowserWorkspace | null = null @@ -436,6 +479,16 @@ export const createBrowserSlice: StateCreator = const closedPages = s.browserPagesByWorkspace[tabId] ?? [] const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } delete nextBrowserPagesByWorkspace[tabId] + remotePagesToClose = closedPages.flatMap((page) => { + const handle = s.remoteBrowserPageHandlesByPageId[page.id] + return handle ? [{ worktreeId: page.worktreeId, handle }] : [] + }) + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + for (const page of closedPages) { + delete nextRemoteBrowserPageHandlesByPageId[page.id] + } const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] @@ -506,10 +559,15 @@ export const createBrowserSlice: StateCreator = pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, activeTabTypeByWorktree: nextActiveTabTypeByWorktree, recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, - recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace + recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } }) + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle) + } + for (const tabs of Object.values(get().unifiedTabsByWorktree)) { const workspaceItem = tabs.find( (entry) => entry.contentType === 'browser' && entry.entityId === tabId @@ -643,7 +701,12 @@ export const createBrowserSlice: StateCreator = // registerGuest uses page IDs (not workspace IDs), so we resolve the active // page within the workspace to find the correct browserPageId. const workspace = findWorkspace(get().browserTabsByWorktree, tabId) - if (workspace?.activePageId && typeof window !== 'undefined' && window.api?.browser) { + if ( + workspace?.activePageId && + !isRuntimeEnvironmentActive(get()) && + typeof window !== 'undefined' && + window.api?.browser + ) { window.api.browser .notifyActiveTabChanged({ browserPageId: workspace.activePageId }) .catch(() => {}) @@ -723,6 +786,8 @@ export const createBrowserSlice: StateCreator = }, closeBrowserPage: (pageId) => { + let closedWorkspaceIdForLabel: string | null = null + const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] set((s) => { const page = findPage(s.browserPagesByWorkspace, pageId) if (!page) { @@ -732,6 +797,7 @@ export const createBrowserSlice: StateCreator = if (!workspace) { return s } + closedWorkspaceIdForLabel = page.workspaceId const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] const nextPages = currentPages.filter((entry) => entry.id !== pageId) const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) @@ -747,6 +813,14 @@ export const createBrowserSlice: StateCreator = }, nextPages ) + const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId] + if (remoteHandle) { + remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle }) + } + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + delete nextRemoteBrowserPageHandlesByPageId[pageId] return { browserPagesByWorkspace: { @@ -777,18 +851,23 @@ export const createBrowserSlice: StateCreator = Object.entries(s.pendingAddressBarFocusByTabId).filter( ([pendingPageId]) => pendingPageId !== pageId ) - ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } }) - const page = findPage(get().browserPagesByWorkspace, pageId) - if (!page) { + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle) + } + + const closedWorkspaceId = closedWorkspaceIdForLabel + if (!closedWorkspaceId) { return } - const workspace = findWorkspace(get().browserTabsByWorktree, page.workspaceId) + const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId) const item = Object.values(get().unifiedTabsByWorktree) .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === page.workspaceId) + .find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId) if (item && workspace) { get().setTabLabel(item.id, workspace.title) } @@ -852,7 +931,11 @@ export const createBrowserSlice: StateCreator = // Why: switching the active page within a workspace changes which guest // webContents the CDP bridge should target for agent commands. - if (typeof window !== 'undefined' && window.api?.browser) { + if ( + !isRuntimeEnvironmentActive(get()) && + typeof window !== 'undefined' && + window.api?.browser + ) { window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {}) } @@ -925,7 +1008,11 @@ export const createBrowserSlice: StateCreator = // Why: notify the CDP bridge which guest webContents is now active so // subsequent agent commands target the correct page. Mirrors the // notifyActiveTabChanged calls in setActiveBrowserTab/setActiveBrowserPage. - if (typeof window !== 'undefined' && window.api?.browser) { + if ( + !isRuntimeEnvironmentActive(get()) && + typeof window !== 'undefined' && + window.api?.browser + ) { window.api.browser.notifyActiveTabChanged({ browserPageId }).catch(() => {}) } @@ -1057,6 +1144,32 @@ export const createBrowserSlice: StateCreator = } }), + setRemoteBrowserPageHandle: (pageId, handle) => { + set((s) => ({ + remoteBrowserPageHandlesByPageId: { + ...s.remoteBrowserPageHandlesByPageId, + [pageId]: handle + } + })) + }, + + removeRemoteBrowserPageHandle: (pageId, remotePageId) => { + let removedHandle: RemoteBrowserPageHandle | null = null + set((s) => { + const current = s.remoteBrowserPageHandlesByPageId[pageId] + if (!current || (remotePageId && current.remotePageId !== remotePageId)) { + return s + } + removedHandle = current + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + delete nextRemoteBrowserPageHandlesByPageId[pageId] + return { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } + }) + return removedHandle + }, + // viewportPresetId is a per-page setting on BrowserPage and is intentionally not // mirrored onto BrowserWorkspace: the outer tab strip doesn't surface the preset, // so there's no UI consumer at the workspace layer. Keeping it page-local avoids @@ -1241,6 +1354,7 @@ export const createBrowserSlice: StateCreator = activeBrowserTabId, activeTabTypeByWorktree: nextActiveTabTypeByWorktree, activeTabType, + remoteBrowserPageHandlesByPageId: {}, browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []) } }) @@ -1281,6 +1395,20 @@ export const createBrowserSlice: StateCreator = }, fetchBrowserSessionProfiles: async () => { + if (isRuntimeEnvironmentActive(get())) { + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileList', + undefined, + { timeoutMs: 15_000 } + ) + set({ browserSessionProfiles: result.profiles }) + } catch { + set({ browserSessionProfiles: [] }) + } + return + } try { const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[] set({ browserSessionProfiles: profiles }) @@ -1290,6 +1418,25 @@ export const createBrowserSlice: StateCreator = }, createBrowserSessionProfile: async (scope, label) => { + if (isRuntimeEnvironmentActive(get())) { + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileCreate', + { scope, label }, + { timeoutMs: 15_000 } + ) + const profile = result.profile + if (profile) { + set((s) => ({ + browserSessionProfiles: [...s.browserSessionProfiles, profile] + })) + } + return profile + } catch { + return null + } + } try { const profile = (await window.api.browser.sessionCreateProfile({ scope, @@ -1307,6 +1454,27 @@ export const createBrowserSlice: StateCreator = }, deleteBrowserSessionProfile: async (profileId) => { + if (isRuntimeEnvironmentActive(get())) { + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileDelete', + { profileId }, + { timeoutMs: 15_000 } + ) + if (result.deleted) { + set((s) => ({ + browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId), + ...(s.defaultBrowserSessionProfileId === profileId + ? { defaultBrowserSessionProfileId: null } + : {}) + })) + } + return result.deleted + } catch { + return false + } + } try { const ok = await window.api.browser.sessionDeleteProfile({ profileId }) if (ok) { @@ -1324,6 +1492,18 @@ export const createBrowserSlice: StateCreator = }, importCookiesToProfile: async (profileId) => { + if (isRuntimeEnvironmentActive(get())) { + const reason = 'Manual cookie file import is unavailable while a remote runtime is active.' + set({ + browserSessionImportState: { + profileId, + status: 'error', + summary: null, + error: reason + } + }) + return { ok: false as const, reason } + } set({ browserSessionImportState: { profileId, @@ -1381,6 +1561,20 @@ export const createBrowserSlice: StateCreator = detectedBrowsersLoaded: false, fetchDetectedBrowsers: async () => { + if (isRuntimeEnvironmentActive(get())) { + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileDetectBrowsers', + undefined, + { timeoutMs: 15_000 } + ) + set({ detectedBrowsers: result.browsers, detectedBrowsersLoaded: true }) + } catch { + set({ detectedBrowsers: [], detectedBrowsersLoaded: true }) + } + return + } if (get().detectedBrowsersLoaded) { return } @@ -1399,6 +1593,58 @@ export const createBrowserSlice: StateCreator = }, importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => { + if (isRuntimeEnvironmentActive(get())) { + set({ + browserSessionImportState: { + profileId, + status: 'importing', + summary: null, + error: null + } + }) + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileImportFromBrowser', + { profileId, browserFamily, browserProfile }, + { timeoutMs: 30_000 } + ) + if (result.ok) { + set({ + browserSessionImportState: { + profileId, + status: 'success', + summary: result.summary, + error: null + } + }) + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } else { + set({ + browserSessionImportState: { + profileId, + status: 'error', + summary: null, + error: result.reason + } + }) + } + return result + } catch (err) { + const reason = String((err as Error)?.message ?? err) + set({ + browserSessionImportState: { + profileId, + status: 'error', + summary: null, + error: reason + } + }) + return { ok: false as const, reason } + } + } set({ browserSessionImportState: { profileId, @@ -1451,6 +1697,22 @@ export const createBrowserSlice: StateCreator = }, clearDefaultSessionCookies: async () => { + if (isRuntimeEnvironmentActive(get())) { + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileClearDefaultCookies', + undefined, + { timeoutMs: 15_000 } + ) + if (result.cleared) { + await get().fetchBrowserSessionProfiles() + } + return result.cleared + } catch { + return false + } + } try { const ok = await window.api.browser.sessionClearDefaultCookies() if (ok) { diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 827d9e3831e..22ba47359e3 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' import type { DiffComment, Worktree } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' // Mock sonner (imported transitively by other slices) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -14,6 +19,13 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { }) const updateMeta = vi.fn().mockResolvedValue({}) +const runtimeEnvironmentCall = vi.fn().mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } +}) +const runtimeEnvironmentTransportCall = vi.fn() const mockApi = { worktrees: { list: vi.fn().mockResolvedValue([]), @@ -21,6 +33,7 @@ const mockApi = { remove: vi.fn().mockResolvedValue(undefined), updateMeta }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, repos: { list: vi.fn().mockResolvedValue([]), add: vi.fn().mockResolvedValue({}), @@ -161,7 +174,18 @@ function seed(store: ReturnType, comments: DiffComment[] describe('updateDiffComment', () => { beforeEach(() => { vi.clearAllMocks() + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) updateMeta.mockResolvedValue({}) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) }) it('updates the body, trims it, and persists', async () => { @@ -189,6 +213,38 @@ describe('updateDiffComment', () => { expect(updateMeta).toHaveBeenCalledTimes(1) }) + it('persists through the selected runtime environment', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never + }) + seed(store, [ + { + id: 'c1', + worktreeId: WT, + filePath: 'src/foo.ts', + lineNumber: 10, + body: 'old body', + createdAt: 1000, + side: 'modified' + } + ]) + + const ok = await store.getState().updateDiffComment(WT, 'c1', 'remote body') + + expect(ok).toBe(true) + expect(updateMeta).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.set', + params: { + worktree: WT, + diffComments: [expect.objectContaining({ id: 'c1', body: 'remote body' })] + }, + timeoutMs: 15_000 + }) + }) + it('rejects an empty body without persisting', async () => { const store = createTestStore() seed(store, [ diff --git a/src/renderer/src/store/slices/diffComments.ts b/src/renderer/src/store/slices/diffComments.ts index c9a8493cd90..7b776d2aa23 100644 --- a/src/renderer/src/store/slices/diffComments.ts +++ b/src/renderer/src/store/slices/diffComments.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { DiffComment, Worktree } from '../../../../shared/types' import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' export type DiffCommentsSlice = { getDiffComments: (worktreeId: string | null | undefined) => DiffComment[] @@ -22,11 +23,25 @@ function generateId(): string { // the sentinel from being corrupted globally. const EMPTY_COMMENTS: readonly DiffComment[] = Object.freeze([]) -async function persist(worktreeId: string, diffComments: DiffComment[]): Promise { - await window.api.worktrees.updateMeta({ - worktreeId, - updates: { diffComments } - }) +async function persist( + settings: AppState['settings'], + worktreeId: string, + diffComments: DiffComment[] +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + await window.api.worktrees.updateMeta({ + worktreeId, + updates: { diffComments } + }) + return + } + await callRuntimeRpc( + target, + 'worktree.set', + { worktree: worktreeId, diffComments }, + { timeoutMs: 15_000 } + ) } // Why: IPC writes from `persist` are not ordered with respect to each other. @@ -55,7 +70,7 @@ function enqueuePersist(worktreeId: string, get: () => AppState): Promise const repoList = get().worktreesByRepo[repoId] const target = repoList?.find((w) => w.id === worktreeId) const latest = target?.diffComments ?? [] - await persist(worktreeId, latest) + await persist(get().settings, worktreeId, latest) } const next = prior.then(run, run) persistQueueByWorktree.set(worktreeId, next) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index b7a3a50e6e1..875946f1d4a 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -4,6 +4,11 @@ import { createStore, type StoreApi } from 'zustand/vanilla' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { createEditorSlice } from './editor' import type { AppState } from '../types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() @@ -107,6 +112,203 @@ describe('createEditorSlice openDiff', () => { }) }) +describe('createEditorSlice untitled cleanup routing', () => { + const runtimeEnvironmentCallMock = vi.fn() + const runtimeEnvironmentTransportCallMock = vi.fn() + const localDeletePathMock = vi.fn() + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockReset() + localDeletePathMock.mockReset() + runtimeEnvironmentCallMock.mockResolvedValue({ ok: true, result: { deleted: true } }) + runtimeEnvironmentTransportCallMock.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCallMock(args) + ) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeEnvironmentTransportCallMock }, + fs: { deletePath: localDeletePathMock } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + function seedRemoteWorktree(store: StoreApi): void { + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: 'repo1', + path: '/remote/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0 + } + ], + worktreesByRepo: { + repo1: [ + { + id: 'wt-1', + repoId: 'repo1', + path: '/remote/wt', + branch: 'refs/heads/main', + head: 'abc', + isBare: false, + isMainWorktree: false, + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + ] + } + } as Partial) + } + + it('closeFile deletes untouched remote untitled files through runtime file RPC', async () => { + const store = createEditorStore() + seedRemoteWorktree(store) + store.getState().openFile({ + filePath: '/remote/wt/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + + store.getState().closeFile('/remote/wt/untitled.md') + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'untitled.md', recursive: undefined }, + timeoutMs: 15_000 + }) + }) + expect(localDeletePathMock).not.toHaveBeenCalled() + }) + + it('closeAllFiles deletes untouched remote untitled files through runtime file RPC', async () => { + const store = createEditorStore() + seedRemoteWorktree(store) + store.getState().openFile({ + filePath: '/remote/wt/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + + store.getState().closeAllFiles() + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'untitled.md', recursive: undefined }, + timeoutMs: 15_000 + }) + }) + expect(localDeletePathMock).not.toHaveBeenCalled() + }) + + it('closeFile uses relative remote delete when worktree metadata is missing', async () => { + const store = createEditorStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [], + worktreesByRepo: {} + } as Partial) + store.getState().openFile({ + filePath: '/remote/wt/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + + store.getState().closeFile('/remote/wt/untitled.md') + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'untitled.md', recursive: undefined }, + timeoutMs: 15_000 + }) + }) + expect(localDeletePathMock).not.toHaveBeenCalled() + }) + + it('closeFile deletes untouched remote untitled files in their owning runtime after switching local', async () => { + const store = createEditorStore() + seedRemoteWorktree(store) + store.getState().openFile({ + filePath: '/remote/wt/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + store.setState({ settings: { activeRuntimeEnvironmentId: null } as never }) + + store.getState().closeFile('/remote/wt/untitled.md') + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'untitled.md', recursive: undefined }, + timeoutMs: 15_000 + }) + }) + expect(localDeletePathMock).not.toHaveBeenCalled() + }) + + it('closeFile deletes untouched remote untitled files in their owning runtime after switching environments', async () => { + const store = createEditorStore() + seedRemoteWorktree(store) + store.getState().openFile({ + filePath: '/remote/wt/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-2' } as never }) + + store.getState().closeFile('/remote/wt/untitled.md') + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'untitled.md', recursive: undefined }, + timeoutMs: 15_000 + }) + }) + expect(localDeletePathMock).not.toHaveBeenCalled() + }) +}) + describe('createEditorSlice markdown view state', () => { it('updates stale language metadata when reopening an existing file', () => { const store = createEditorStore() @@ -1047,13 +1249,30 @@ describe('createEditorSlice activateMarkdownLink', () => { const openFileUriMock = vi.fn() const pathExistsMock = vi.fn() const authorizeExternalPathMock = vi.fn() + const runtimeEnvironmentCallMock = vi.fn() + const runtimeEnvironmentTransportCallMock = vi.fn() beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() toastErrorMock.mockReset() openUrlMock.mockReset() openFileUriMock.mockReset() pathExistsMock.mockReset() + pathExistsMock.mockResolvedValue(true) authorizeExternalPathMock.mockReset() + runtimeEnvironmentCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockReset() + runtimeEnvironmentCallMock.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { size: 1, isDirectory: false, mtime: 1 }, + _meta: { runtimeId: 'runtime-source' } + }) + runtimeEnvironmentTransportCallMock.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args, 'runtime-source') ?? + runtimeEnvironmentCallMock(args) + ) openHttpLinkMock.mockReset() // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(globalThis as any).window = (globalThis as any).window ?? {} @@ -1065,7 +1284,17 @@ describe('createEditorSlice activateMarkdownLink', () => { pathExists: pathExistsMock }, fs: { - authorizeExternalPath: authorizeExternalPathMock + authorizeExternalPath: authorizeExternalPathMock, + stat: vi.fn(async ({ filePath }: { filePath: string }) => { + const exists = await pathExistsMock(filePath) + if (!exists) { + throw new Error('File not found') + } + return { size: 1, isDirectory: false, mtime: 1 } + }) + }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCallMock } } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1100,6 +1329,70 @@ describe('createEditorSlice activateMarkdownLink', () => { expect(openUrlMock).not.toHaveBeenCalled() }) + it('opens remote-owned markdown links through the source file runtime owner', async () => { + const store = createEditorStore() + pathExistsMock.mockResolvedValue(true) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-active' } as AppState['settings'] + }) + store.getState().openFile({ + filePath: '/repo/docs/note.md', + relativePath: 'docs/note.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: 'env-source', + language: 'markdown', + mode: 'edit' + }) + + await store.getState().activateMarkdownLink('./guide.md', { + sourceFilePath: '/repo/docs/note.md', + worktreeId: 'wt-1', + worktreeRoot: '/repo' + }) + + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-source', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'docs/guide.md' }, + timeoutMs: 15_000 + }) + expect(store.getState().openFiles).toEqual([ + expect.objectContaining({ + filePath: '/repo/docs/note.md', + runtimeEnvironmentId: 'env-source' + }), + expect.objectContaining({ + filePath: '/repo/docs/guide.md', + runtimeEnvironmentId: 'env-source', + mode: 'edit', + isPreview: true + }) + ]) + }) + + it('can open a file without adopting the currently active runtime owner', () => { + const store = createEditorStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-active' } as AppState['settings'] + }) + + store.getState().openFile( + { + filePath: '/remote/.orca/drops/log.txt', + relativePath: '.orca/drops/log.txt', + worktreeId: 'wt-1', + language: 'text', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + expect(store.getState().openFiles[0]).toMatchObject({ + filePath: '/remote/.orca/drops/log.txt' + }) + expect(store.getState().openFiles[0]?.runtimeEnvironmentId).toBeUndefined() + }) + it('toasts when the markdown target is missing', async () => { const store = createEditorStore() pathExistsMock.mockResolvedValue(false) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 04f77a127cc..4faba8b6fbc 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -23,6 +23,19 @@ import type { } from '../../../../shared/types' import { stripCredentialsFromMessage } from '../../../../shared/git-remote-error' import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action' +import { + fetchRuntimeGit, + getRuntimeGitUpstreamStatus, + pullRuntimeGit, + pushRuntimeGit +} from '@/runtime/runtime-git-client' +import { + deleteRuntimePath, + deleteRuntimeRelativePath, + runtimePathExists +} from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' export type DiffSource = | 'unstaged' @@ -91,6 +104,9 @@ export type OpenFile = { worktreeId: string language: string isDirty: boolean + // Why: remote untitled cleanup must target the environment that created the + // file, even if the user switches to Local or another runtime before closing. + runtimeEnvironmentId?: string /** Why: markdown preview tabs are separate editor tabs that mirror a source * markdown file's live draft. Storing the source file ID lets the preview * follow unsaved edits from the normal editor without becoming editable @@ -199,7 +215,12 @@ export type EditorSlice = { setActiveTabType: (type: WorkspaceVisibleTabType) => void openFile: ( file: Omit, - options?: { preview?: boolean; targetGroupId?: string; recordReplacedPreview?: boolean } + options?: { + preview?: boolean + targetGroupId?: string + recordReplacedPreview?: boolean + suppressActiveRuntimeFallback?: boolean + } ) => void // Why: dispatcher for markdown link activation. Lives on the slice because it // sequences openFile, setMarkdownViewMode, and setPendingEditorReveal around @@ -207,10 +228,18 @@ export type EditorSlice = { // docs/markdown-internal-link-opening-design.md. activateMarkdownLink: ( rawHref: string | undefined, - ctx: { sourceFilePath: string; worktreeId: string; worktreeRoot: string | null } + ctx: { + sourceFilePath: string + worktreeId: string + worktreeRoot: string | null + runtimeEnvironmentId?: string | null + } ) => Promise openMarkdownPreview: ( - file: Pick, + file: Pick< + OpenFile, + 'filePath' | 'relativePath' | 'worktreeId' | 'language' | 'runtimeEnvironmentId' + >, options?: { anchor?: string | null; targetGroupId?: string } ) => void pinFile: (fileId: string, tabId?: string) => void @@ -494,6 +523,31 @@ function resolveRemoteOperationErrorMessage( return error.message } +function deleteUntouchedUntitledFile(state: AppState, file: OpenFile): void { + const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId) + const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(file.worktreeId) + const repo = state.repos.find((candidate) => candidate.id === repoId) + const owningRuntimeEnvironmentId = file.runtimeEnvironmentId?.trim() + // Why: untitled placeholders may live on a remote runtime or SSH target. + // Route through the runtime-aware client instead of assuming client-local FS. + const context = { + settings: owningRuntimeEnvironmentId + ? { activeRuntimeEnvironmentId: owningRuntimeEnvironmentId } + : state.settings, + worktreeId: file.worktreeId, + worktreePath: worktree?.path ?? null, + connectionId: repo?.connectionId ?? undefined + } + void deleteRuntimeRelativePath(context, file.relativePath) + .then((deletedRemotely) => { + if (!deletedRemotely && !owningRuntimeEnvironmentId) { + return deleteRuntimePath(context, file.filePath) + } + return undefined + }) + .catch(() => {}) +} + export const createEditorSlice: StateCreator = (set, get) => ({ editorDrafts: {}, setEditorDraft: (fileId, content) => @@ -606,6 +660,11 @@ export const createEditorSlice: StateCreator = (s const id = file.filePath const existing = s.openFiles.find((f) => f.id === id) const worktreeId = file.worktreeId + const runtimeEnvironmentId = + file.runtimeEnvironmentId ?? + (options?.suppressActiveRuntimeFallback + ? undefined + : (s.settings?.activeRuntimeEnvironmentId?.trim() ?? undefined)) const isPreview = options?.preview ?? false const recordReplacedPreview = options?.recordReplacedPreview ?? false // Why: resolve the target group up-front so preview replacement can be @@ -647,7 +706,8 @@ export const createEditorSlice: StateCreator = (s existing.isPreview !== updatedPreview || existing.language !== file.language || existing.relativePath !== file.relativePath || - existing.worktreeId !== file.worktreeId + existing.worktreeId !== file.worktreeId || + existing.runtimeEnvironmentId !== runtimeEnvironmentId if (!needsExistingUpdate) { return activeResult } @@ -659,6 +719,7 @@ export const createEditorSlice: StateCreator = (s relativePath: file.relativePath, worktreeId: file.worktreeId, language: file.language, + runtimeEnvironmentId, mode: file.mode, diffSource: file.diffSource, branchCompare: file.branchCompare, @@ -729,7 +790,9 @@ export const createEditorSlice: StateCreator = (s ) // Replace in-place to preserve tab position newFiles = s.openFiles.map((f, i) => - i === existingPreviewIdx ? { ...file, id, isDirty: false, isPreview: true } : f + i === existingPreviewIdx + ? { ...file, id, isDirty: false, isPreview: true, runtimeEnvironmentId } + : f ) // Swap the old preview ID for the new one in the stored tab bar order const prevOrder = s.tabBarOrderByWorktree?.[worktreeId] @@ -799,7 +862,13 @@ export const createEditorSlice: StateCreator = (s return { openFiles: [ ...newFiles, - { ...file, id, isDirty: false, isPreview: isPreview || undefined } + { + ...file, + id, + isDirty: false, + isPreview: isPreview || undefined, + runtimeEnvironmentId + } ], ...tabBarUpdate, ...activeResult @@ -826,6 +895,8 @@ export const createEditorSlice: StateCreator = (s set((s) => { const existing = s.openFiles.find((openFile) => openFile.id === id) const worktreeId = file.worktreeId + const runtimeEnvironmentId = + file.runtimeEnvironmentId ?? s.settings?.activeRuntimeEnvironmentId?.trim() ?? undefined const activeResult = { activeFileId: id, activeTabType: 'editor' as const, @@ -851,6 +922,7 @@ export const createEditorSlice: StateCreator = (s relativePath: file.relativePath, worktreeId: file.worktreeId, language: file.language, + runtimeEnvironmentId, markdownPreviewSourceFileId: file.filePath, markdownPreviewAnchor: anchor, mode: 'markdown-preview' as const @@ -869,6 +941,7 @@ export const createEditorSlice: StateCreator = (s worktreeId: file.worktreeId, language: file.language, isDirty: false, + runtimeEnvironmentId, markdownPreviewSourceFileId: file.filePath, markdownPreviewAnchor: anchor, mode: 'markdown-preview' @@ -1075,7 +1148,7 @@ export const createEditorSlice: StateCreator = (s // tab without typing anything, the file is just clutter. Fire-and-forget // delete; failure (e.g. already removed externally) is harmless. if (shouldDeleteFromDisk && preClose && typeof window !== 'undefined') { - void window.api?.fs?.deletePath({ targetPath: preClose.filePath })?.catch(() => {}) + deleteUntouchedUntitledFile(get(), preClose) } // Why: the unified tab model drives visual tab-bar order and next-active @@ -1242,8 +1315,9 @@ export const createEditorSlice: StateCreator = (s } }) if (typeof window !== 'undefined') { + const postCloseState = get() for (const f of untitledToDelete) { - void window.api?.fs?.deletePath({ targetPath: f.filePath })?.catch(() => {}) + deleteUntouchedUntitledFile(postCloseState, f) } } for (const itemId of closingItemIds) { @@ -1904,7 +1978,9 @@ export const createEditorSlice: StateCreator = (s }), fetchUpstreamStatus: async (worktreeId, worktreePath, connectionId) => { try { - const status = await window.api.git.upstreamStatus({ + const status = await getRuntimeGitUpstreamStatus({ + settings: get().settings, + worktreeId, worktreePath, connectionId }) @@ -1931,7 +2007,10 @@ export const createEditorSlice: StateCreator = (s // store as soon as the IPC resolves. get().beginRemoteOperation(publish ? 'publish' : 'push') try { - await window.api.git.push({ worktreePath, publish, connectionId, pushTarget }) + await pushRuntimeGit( + { settings: get().settings, worktreeId, worktreePath, connectionId }, + { publish, pushTarget } + ) } catch (error) { toast.error(resolveRemoteOperationErrorMessage(error, { publish, isPush: true })) throw error @@ -1943,7 +2022,7 @@ export const createEditorSlice: StateCreator = (s pullBranch: async (worktreeId, worktreePath, connectionId) => { get().beginRemoteOperation('pull') try { - await window.api.git.pull({ worktreePath, connectionId }) + await pullRuntimeGit({ settings: get().settings, worktreeId, worktreePath, connectionId }) } catch (error) { toast.error(resolveRemoteOperationErrorMessage(error)) throw error @@ -1963,23 +2042,17 @@ export const createEditorSlice: StateCreator = (s // outer catch must then skip toasting to avoid a double-toast. let pushStageToastShown = false try { - await window.api.git.fetch({ worktreePath, connectionId }) - await window.api.git.pull({ worktreePath, connectionId }) + const context = { settings: get().settings, worktreeId, worktreePath, connectionId } + await fetchRuntimeGit(context) + await pullRuntimeGit(context) // Why: push only if the pull left local commits that aren't on the // remote. After a merge pull the ahead count can be >0 (local commits + // the new merge commit) or 0 (pure fast-forward), and we avoid a // no-op push round-trip in the fast-forward case. - const upstreamStatus = await window.api.git.upstreamStatus({ - worktreePath, - connectionId - }) + const upstreamStatus = await getRuntimeGitUpstreamStatus(context) if (upstreamStatus.ahead > 0) { try { - await window.api.git.push({ - worktreePath, - connectionId, - pushTarget - }) + await pushRuntimeGit(context, { pushTarget }) } catch (error) { // Why: format under the user-facing operation (sync) rather than // the inner step (push) — the user clicked Sync and shouldn't see @@ -2010,7 +2083,7 @@ export const createEditorSlice: StateCreator = (s // ahead/behind counts on the upstream-status payload. get().beginRemoteOperation('fetch') try { - await window.api.git.fetch({ worktreePath, connectionId }) + await fetchRuntimeGit({ settings: get().settings, worktreeId, worktreePath, connectionId }) } catch (error) { toast.error(resolveRemoteOperationErrorMessage(error)) throw error @@ -2142,6 +2215,11 @@ export const createEditorSlice: StateCreator = (s setPendingEditorReveal: (reveal) => set({ pendingEditorReveal: reveal }), activateMarkdownLink: async (rawHref, ctx) => { + const sourceRuntimeEnvironmentId = + ctx.runtimeEnvironmentId ?? + get().openFiles.find((file) => file.filePath === ctx.sourceFilePath)?.runtimeEnvironmentId ?? + null + const sourceSettings = settingsForRuntimeOwner(get().settings, sourceRuntimeEnvironmentId) const target = resolveMarkdownLinkTarget(rawHref, ctx.sourceFilePath, ctx.worktreeRoot) if (!target) { return @@ -2155,6 +2233,13 @@ export const createEditorSlice: StateCreator = (s } if (target.kind === 'file') { if (target.relativePath === undefined) { + if (sourceSettings?.activeRuntimeEnvironmentId?.trim()) { + // Why: a file:// link outside the worktree is a client-local escape + // hatch. Remote runtime editors must not authorize/open client paths + // as though the server could read them. + toast.error('External local file links are not available for remote runtime files yet.') + return + } // Why: terminal file links already authorize clicked external paths // before opening them in Orca. Markdown file:// links need the same // user-gesture authorization so /tmp screenshots can use ImageViewer. @@ -2166,6 +2251,7 @@ export const createEditorSlice: StateCreator = (s filePath: target.absolutePath, relativePath: target.relativePath ?? target.absolutePath, worktreeId: ctx.worktreeId, + runtimeEnvironmentId: sourceRuntimeEnvironmentId ?? undefined, language: detectLanguage(target.absolutePath), mode: 'edit' }, @@ -2180,14 +2266,25 @@ export const createEditorSlice: StateCreator = (s // target.kind === 'markdown' const { absolutePath, relativePath, line, column } = target - const exists = await window.api.shell.pathExists(absolutePath) + const exists = await runtimePathExists( + { + settings: sourceSettings, + worktreeId: ctx.worktreeId, + worktreePath: ctx.worktreeRoot + }, + absolutePath + ) if (!exists) { toast.error(`File not found: ${relativePath}`) return } const state = get() - const existing = state.openFiles.find((f) => f.filePath === absolutePath) + const existing = state.openFiles.find( + (f) => + f.filePath === absolutePath && + (f.runtimeEnvironmentId ?? null) === sourceRuntimeEnvironmentId + ) const fileId = existing?.id ?? absolutePath // Why: pendingEditorReveal is consumed by MonacoEditor on mount. If the @@ -2205,6 +2302,7 @@ export const createEditorSlice: StateCreator = (s filePath: absolutePath, relativePath, worktreeId: ctx.worktreeId, + runtimeEnvironmentId: sourceRuntimeEnvironmentId ?? undefined, language: 'markdown', mode: 'edit' }, @@ -2272,6 +2370,7 @@ export const createEditorSlice: StateCreator = (s language: detectLanguage(pf.relativePath || pf.filePath), isDirty: false, isPreview: pf.isPreview, + runtimeEnvironmentId: pf.runtimeEnvironmentId, mode: 'edit' }) } diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 3229a588db2..6fbae94e642 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -6,13 +6,25 @@ import { create } from 'zustand' import { createGitHubSlice } from './github' import type { AppState } from '../types' import type { PRInfo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() const mockApi = { gh: { prForBranch: vi.fn().mockResolvedValue(null), issue: vi.fn().mockResolvedValue(null), prChecks: vi.fn().mockResolvedValue([]), - listWorkItems: vi.fn() + listWorkItems: vi.fn(), + getProjectViewTable: vi.fn() + }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall }, cache: { getGitHub: vi.fn().mockResolvedValue(null), @@ -23,6 +35,15 @@ const mockApi = { // @ts-expect-error test window mock globalThis.window = { api: mockApi } +function resetRemoteRuntimeMocks() { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) +} + function createTestStore() { return create()( (...a) => @@ -49,6 +70,7 @@ function makePR(overrides: Partial = {}): PRInfo { describe('createGitHubSlice.fetchPRChecks', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() mockApi.gh.prChecks.mockResolvedValue([]) }) @@ -230,6 +252,7 @@ describe('createGitHubSlice.fetchPRChecks', () => { describe('createGitHubSlice.fetchPRForBranch', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() mockApi.gh.prForBranch.mockResolvedValue(null) }) @@ -265,6 +288,13 @@ describe('createGitHubSlice.fetchPRForBranch', () => { describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { items: [], sources: { issues: null, prs: null, upstreamCandidate: null } }, + _meta: { runtimeId: 'remote-runtime' } + }) }) it('stores resolved sources on the cache entry for the indicator to read', async () => { @@ -342,6 +372,104 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { const after = store.getState().getWorkItemsSourcesAndError('/repo', 24, '') expect(after.error).toBeNull() }) + + it('routes work item fetches through the active runtime environment', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + repos: [ + { + id: 'repo-id', + path: '/server/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ] + } as Partial) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + items: [{ type: 'issue', number: 7, title: 'Server issue', url: 'https://example.test/7' }], + sources: { issues: { owner: 'up', repo: 'r' }, prs: { owner: 'up', repo: 'r' } } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await store.getState().fetchWorkItems('repo-id', '/server/repo', 24, '') + + expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.listWorkItems', + params: { repo: 'repo-id', limit: 24, query: undefined }, + timeoutMs: 30_000 + }) + expect(store.getState().workItemsCache['/server/repo::24::'].data?.[0]).toMatchObject({ + repoId: 'repo-id', + number: 7 + }) + }) + + it('routes project table fetches through the active runtime environment', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } + } as Partial) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + ok: true, + data: { + project: { + id: 'project-1', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [], + groupByFields: [], + sortByFields: [] + }, + rows: [], + totalCount: 0, + parentFieldDropped: false + } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + const result = await store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + }) + + expect(result.ok).toBe(true) + expect(mockApi.gh.getProjectViewTable).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.project.viewTable', + params: { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + }, + timeoutMs: 60_000 + }) + }) }) describe('IssueSourceIndicator suppression', () => { diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index e77901aba9f..4f007e839f1 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -11,6 +11,7 @@ import type { IssueInfo, PRCheckDetail, PRComment, + Repo, Worktree, GitHubWorkItem } from '../../../../shared/types' @@ -25,6 +26,7 @@ import type { } from '../../../../shared/github-project-types' import { sortWorkItemsByUpdatedAt, PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' import { syncPRChecksStatus } from './github-checks' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: declared separately from CacheEntry (not a generified E parameter) @@ -77,6 +79,18 @@ function queryOverrideKeyPart(queryOverride: string | undefined): string { return `:q=${queryOverride}` } +function getRuntimeRepoTarget( + state: AppState, + repoPath: string +): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { + const target = getActiveRuntimeTarget(state.settings) + if (target.kind !== 'environment') { + return null + } + const repo = state.repos.find((candidate) => candidate.path === repoPath) + return repo ? { target, repo } : null +} + export function projectViewCacheKey( ownerType: GetProjectViewTableArgs['ownerType'], owner: string, @@ -616,7 +630,16 @@ export const createGitHubSlice: StateCreator = (s const request = (async (): Promise => { await acquireWorkItemSlot() try { - const envelope = await window.api.gh.getProjectViewTable(args) + const target = getActiveRuntimeTarget(get().settings) + const envelope = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.viewTable', + args, + { timeoutMs: 60_000 } + ) + : await window.api.gh.getProjectViewTable(args) if (envelope.ok) { const table = envelope.data const key = projectViewCacheKey( @@ -703,12 +726,26 @@ export const createGitHubSlice: StateCreator = (s } applyRowPatch(set, cacheKey, rowId, optimisticRow) - const result = await window.api.gh.updateProjectItemField({ - projectId: table.project.id, - itemId: rowId, - fieldId, - value - }) + const target = getActiveRuntimeTarget(get().settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateItemField', + { + projectId: table.project.id, + itemId: rowId, + fieldId, + value + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateProjectItemField({ + projectId: table.project.id, + itemId: rowId, + fieldId, + value + }) if (!result.ok) { rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) } @@ -741,11 +778,24 @@ export const createGitHubSlice: StateCreator = (s } applyRowPatch(set, cacheKey, rowId, optimisticRow) - const result = await window.api.gh.clearProjectItemField({ - projectId: table.project.id, - itemId: rowId, - fieldId - }) + const target = getActiveRuntimeTarget(get().settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.clearItemField', + { + projectId: table.project.id, + itemId: rowId, + fieldId + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.clearProjectItemField({ + projectId: table.project.id, + itemId: rowId, + fieldId + }) if (!result.ok) { rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) } @@ -820,11 +870,12 @@ export const createGitHubSlice: StateCreator = (s // PRs goes through updatePullRequestBySlug; for issues through // updateIssueBySlug. We dispatch both as needed. let envelope: GitHubProjectMutationResult = { ok: true } + const target = getActiveRuntimeTarget(get().settings) if ( previousRow.itemType === 'PULL_REQUEST' && (updates.title !== undefined || updates.body !== undefined) ) { - const prRes = await window.api.gh.updatePullRequestBySlug({ + const args = { owner, repo, number, @@ -832,7 +883,16 @@ export const createGitHubSlice: StateCreator = (s ...(updates.title !== undefined ? { title: updates.title } : {}), ...(updates.body !== undefined ? { body: updates.body } : {}) } - }) + } + const prRes = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updatePullRequestBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePullRequestBySlug(args) if (!prRes.ok) { envelope = prRes } @@ -846,7 +906,7 @@ export const createGitHubSlice: StateCreator = (s (previousRow.itemType === 'ISSUE' && (updates.title !== undefined || updates.body !== undefined))) ) { - const issueRes = await window.api.gh.updateIssueBySlug({ + const args = { owner, repo, number, @@ -858,7 +918,16 @@ export const createGitHubSlice: StateCreator = (s ...(updates.addAssignees ? { addAssignees: updates.addAssignees } : {}), ...(updates.removeAssignees ? { removeAssignees: updates.removeAssignees } : {}) } - }) + } + const issueRes = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateIssueBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueBySlug(args) if (!issueRes.ok) { envelope = issueRes } @@ -899,12 +968,22 @@ export const createGitHubSlice: StateCreator = (s content: { ...previousRow.content, issueType } } applyRowPatch(set, cacheKey, rowId, optimistic) - const res = await window.api.gh.updateIssueTypeBySlug({ + const target = getActiveRuntimeTarget(get().settings) + const args = { owner, repo, number, issueTypeId: issueType?.id ?? null - }) + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateIssueTypeBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueTypeBySlug(args) if (!res.ok) { rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) } @@ -1008,11 +1087,19 @@ export const createGitHubSlice: StateCreator = (s const request = (async () => { await acquireWorkItemSlot() try { - const envelope = await window.api.gh.listWorkItems({ - repoPath, - limit, - query: query || undefined - }) + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const envelope = runtimeRepo + ? await callRuntimeRpc>>( + runtimeRepo.target, + 'github.listWorkItems', + { repo: runtimeRepo.repo.id, limit, query: query || undefined }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.listWorkItems({ + repoPath, + limit, + query: query || undefined + }) // Why: stamp repoId at the renderer fetch boundary so every downstream // consumer (cross-repo merge, row rendering, drawer) can rely on the // field being present. Main doesn't know Orca's Repo.id. @@ -1103,12 +1190,25 @@ export const createGitHubSlice: StateCreator = (s repos.map(async (r) => { await acquireWorkItemSlot() try { - const envelope = await window.api.gh.listWorkItems({ - repoPath: r.path, - limit: perRepoLimit, - query: query || undefined, - before - }) + const runtimeRepo = getRuntimeRepoTarget(get(), r.path) + const envelope = runtimeRepo + ? await callRuntimeRpc>>( + runtimeRepo.target, + 'github.listWorkItems', + { + repo: runtimeRepo.repo.id, + limit: perRepoLimit, + query: query || undefined, + before + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.listWorkItems({ + repoPath: r.path, + limit: perRepoLimit, + query: query || undefined, + before + }) // Why: page-N partial failures don't participate in the cache's per-repo // error banner (which is keyed on the initial-fetch cache entry). Log the // classified issues-side error so pagination failures are at least @@ -1140,10 +1240,18 @@ export const createGitHubSlice: StateCreator = (s const counts = await Promise.all( repos.map(async (r) => { try { - return await window.api.gh.countWorkItems({ - repoPath: r.path, - query: query || undefined - }) + const runtimeRepo = getRuntimeRepoTarget(get(), r.path) + return runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.countWorkItems', + { repo: runtimeRepo.repo.id, query: query || undefined }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.countWorkItems({ + repoPath: r.path, + query: query || undefined + }) } catch { return 0 } @@ -1201,7 +1309,15 @@ export const createGitHubSlice: StateCreator = (s const linkedPRNumber = options?.linkedPRNumber ?? null const request = (async () => { try { - const pr = await window.api.gh.prForBranch({ repoPath, branch, linkedPRNumber }) + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const pr = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.prForBranch', + { repo: runtimeRepo.repo.id, branch, linkedPRNumber }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.prForBranch({ repoPath, branch, linkedPRNumber }) if (prRequestGenerations.get(cacheKey) === generation) { set((s) => ({ prCache: { ...s.prCache, [cacheKey]: { data: pr, fetchedAt: Date.now() } } @@ -1248,7 +1364,15 @@ export const createGitHubSlice: StateCreator = (s const request = (async () => { try { - const issue = await window.api.gh.issue({ repoPath, number }) + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const issue = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.issue', + { repo: runtimeRepo.repo.id, number }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.issue({ repoPath, number }) set((s) => ({ issueCache: { ...s.issueCache, [cacheKey]: { data: issue, fetchedAt: Date.now() } } })) @@ -1290,12 +1414,20 @@ export const createGitHubSlice: StateCreator = (s const request = (async () => { try { - const checks = (await window.api.gh.prChecks({ - repoPath, - prNumber, - headSha, - noCache: options?.force - })) as PRCheckDetail[] + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const checks = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.prChecks', + { repo: runtimeRepo.repo.id, prNumber, headSha, noCache: options?.force }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prChecks({ + repoPath, + prNumber, + headSha, + noCache: options?.force + })) as PRCheckDetail[]) set((s) => { const nextState: Partial = { checksCache: { ...s.checksCache, [cacheKey]: { data: checks, fetchedAt: Date.now() } } @@ -1336,11 +1468,19 @@ export const createGitHubSlice: StateCreator = (s const request = (async () => { try { - const comments = (await window.api.gh.prComments({ - repoPath, - prNumber, - noCache: options?.force - })) as PRComment[] + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const comments = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.prComments', + { repo: runtimeRepo.repo.id, prNumber, noCache: options?.force }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prComments({ + repoPath, + prNumber, + noCache: options?.force + })) as PRComment[]) set((s) => ({ commentsCache: { ...s.commentsCache, @@ -1378,7 +1518,15 @@ export const createGitHubSlice: StateCreator = (s })) } - const ok = await window.api.gh.resolveReviewThread({ repoPath, threadId, resolve }) + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const ok = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.resolveReviewThread', + { repo: runtimeRepo.repo.id, threadId, resolve }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.resolveReviewThread({ repoPath, threadId, resolve }) if (!ok && prev) { // Revert optimistic update on failure set((s) => ({ @@ -1529,10 +1677,11 @@ export const createGitHubSlice: StateCreator = (s // `repos:changed` broadcast → other windows re-fetch. The store layer // normalizes `'auto'` to `undefined` so the persisted record drops // the key entirely (see main/persistence.ts#updateRepo). - await window.api.repos.update({ - repoId, - updates: { issueSourcePreference: preference === 'auto' ? undefined : preference } - }) + const updates = { issueSourcePreference: preference === 'auto' ? undefined : preference } + const target = getActiveRuntimeTarget(get().settings) + await (target.kind === 'local' + ? window.api.repos.update({ repoId, updates }) + : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) } catch (err) { console.error('Failed to persist issue-source preference:', err) // Why: surface the persist failure so the user understands why the diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 08c217e3cba..3f90fb4bfbb 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -4,6 +4,20 @@ import type { AppState } from '../types' import { createHostedReviewSlice } from './hosted-review' import type { HostedReviewInfo } from '../../../../shared/hosted-review' +const runtimeRpc = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: runtimeRpc.callRuntimeRpc, + getActiveRuntimeTarget: ( + settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined + ) => { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } + } +})) + const mockApi = { hostedReview: { forBranch: vi.fn() @@ -12,9 +26,12 @@ const mockApi = { globalThis.window = { api: mockApi } as never -function makeStore() { - return create>()((...args) => - createHostedReviewSlice(...(args as Parameters)) +function makeStore(settings: AppState['settings'] = null) { + return create>()( + (...args) => ({ + settings, + ...createHostedReviewSlice(...(args as Parameters)) + }) ) } @@ -32,6 +49,7 @@ const review: HostedReviewInfo = { describe('hosted review slice', () => { beforeEach(() => { mockApi.hostedReview.forBranch.mockReset() + runtimeRpc.callRuntimeRpc.mockReset() }) it('fetches and caches branch review status through the common IPC surface', async () => { @@ -56,4 +74,31 @@ describe('hosted review slice', () => { linkedBitbucketPR: null }) }) + + it('routes active runtime review lookups through runtime RPC', async () => { + runtimeRpc.callRuntimeRpc.mockResolvedValueOnce(review) + const store = makeStore({ + activeRuntimeEnvironmentId: 'env-win' + } as AppState['settings']) + + await expect( + store.getState().fetchHostedReviewForBranch('C:\\repo', 'feature/windows', { + linkedGitHubPR: 12 + }) + ).resolves.toEqual(review) + + expect(mockApi.hostedReview.forBranch).not.toHaveBeenCalled() + expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-win' }, + 'hostedReview.forBranch', + { + repo: 'C:\\repo', + branch: 'feature/windows', + linkedGitHubPR: 12, + linkedGitLabMR: null, + linkedBitbucketPR: null + }, + { timeoutMs: 30_000 } + ) + }) }) diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 0abbc0733ac..39b545f62e2 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -1,5 +1,7 @@ import type { StateCreator } from 'zustand' import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { GlobalSettings } from '../../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { AppState } from '../types' type CacheEntry = { data: T | null; fetchedAt: number } @@ -17,6 +19,16 @@ function isFresh(entry: CacheEntry | undefined): entry is CacheEntry { return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS } +export function getHostedReviewCacheKey( + repoPath: string, + branch: string, + settings?: Pick | null +): string { + const target = getActiveRuntimeTarget(settings) + const scope = target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' + return `${scope}::${repoPath}::${branch}` +} + export type HostedReviewSlice = { hostedReviewCache: Record> fetchHostedReviewForBranch: ( @@ -41,7 +53,9 @@ export const createHostedReviewSlice: StateCreator => { - const cacheKey = `${repoPath}::${branch}` + const settings = get().settings + const target = getActiveRuntimeTarget(settings) + const cacheKey = getHostedReviewCacheKey(repoPath, branch, settings) const cached = get().hostedReviewCache[cacheKey] const linkedRefetch = cached?.data === null && @@ -62,13 +76,25 @@ export const createHostedReviewSlice: StateCreator { try { - const review = await window.api.hostedReview.forBranch({ - repoPath, + const args = { branch, linkedGitHubPR: options?.linkedGitHubPR ?? null, linkedGitLabMR: options?.linkedGitLabMR ?? null, linkedBitbucketPR: options?.linkedBitbucketPR ?? null - }) + } + const review = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'hostedReview.forBranch', + { repo: repoPath, ...args }, + // Why: remote dev boxes can be slower at `git`/`gh` lookups + // than local desktop repos, especially on Windows filesystem + // paths. The main-process queue caps concurrency, so a longer + // timeout no longer risks a background socket stampede. + { timeoutMs: 30_000 } + ) + : await window.api.hostedReview.forBranch({ repoPath, ...args }) if (requestGenerations.get(cacheKey) === generation) { set((state) => ({ hostedReviewCache: { diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 34098ee337c..084c8e29cf9 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -3,6 +3,15 @@ import type { AppState } from '../types' import type { LinearViewer, LinearConnectionStatus, LinearIssue } from '../../../../shared/types' import type { CacheEntry } from './github' import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' +import { + linearConnect, + linearDisconnect, + linearGetIssue, + linearListIssues, + linearSearchIssues, + linearStatus, + linearTestConnection +} from '@/runtime/runtime-linear-client' const CACHE_TTL = 60_000 // 60s — same as GitHub work-items TTL const MAX_CACHE_ENTRIES = 500 @@ -67,7 +76,7 @@ export const createLinearSlice: StateCreator = (s checkLinearConnection: async () => { try { - const status = (await window.api.linear.status()) as LinearConnectionStatus + const status = (await linearStatus(get().settings)) as LinearConnectionStatus const prev = get().linearStatus if (prev.connected !== status.connected || prev.viewer?.email !== status.viewer?.email) { set({ linearStatus: status, linearStatusChecked: true }) @@ -85,7 +94,7 @@ export const createLinearSlice: StateCreator = (s testLinearConnection: async () => { try { - const result = (await window.api.linear.testConnection()) as + const result = (await linearTestConnection(get().settings)) as | { ok: true; viewer: LinearViewer } | { ok: false; error: string } if (result.ok) { @@ -110,7 +119,7 @@ export const createLinearSlice: StateCreator = (s connectLinear: async (apiKey: string) => { try { - const result = await window.api.linear.connect({ apiKey }) + const result = await linearConnect(get().settings, apiKey) if (result.ok) { set({ linearStatus: { @@ -127,7 +136,7 @@ export const createLinearSlice: StateCreator = (s }, disconnectLinear: async () => { - await window.api.linear.disconnect() + await linearDisconnect(get().settings) inflightIssueRequests.clear() inflightSearchRequests.clear() inflightListRequests.clear() @@ -150,8 +159,7 @@ export const createLinearSlice: StateCreator = (s return inflight } - const promise = window.api.linear - .getIssue({ id }) + const promise = linearGetIssue(get().settings, id) .then((issue) => { const data = issue as LinearIssue | null set((s) => ({ @@ -189,8 +197,7 @@ export const createLinearSlice: StateCreator = (s return inflight } - const promise = window.api.linear - .searchIssues({ query, limit }) + const promise = linearSearchIssues(get().settings, query, limit) .then((issues) => { const data = issues as LinearIssue[] set((s) => ({ @@ -228,8 +235,7 @@ export const createLinearSlice: StateCreator = (s return inflight } - const promise = window.api.linear - .listIssues({ filter, limit }) + const promise = linearListIssues(get().settings, filter, limit) .then((issues) => { const data = issues as LinearIssue[] set((s) => ({ diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts new file mode 100644 index 00000000000..ea268eb5b0b --- /dev/null +++ b/src/renderer/src/store/slices/repos.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +import type { Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 +} + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2 +} + +const reposList = vi.fn() +const reposAdd = vi.fn() +const reposPickFolder = vi.fn() +const reposRemove = vi.fn() +const reposUpdate = vi.fn() +const reposReorder = vi.fn() +const ptyKill = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + reposAdd.mockReset() + reposPickFolder.mockReset() + reposRemove.mockReset() + reposUpdate.mockReset() + reposReorder.mockReset() + ptyKill.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList, + add: reposAdd, + pickFolder: reposPickFolder, + remove: reposRemove, + update: reposUpdate, + reorder: reposReorder + }, + pty: { kill: ptyKill }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice runtime routing', () => { + it('fetches repos from local IPC when no remote environment is active', async () => { + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + + await store.getState().fetchRepos() + + expect(store.getState().repos).toEqual([localRepo]) + expect(reposList).toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('fetches repos from the active remote runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + activeRepoId: 'stale-repo', + filterRepoIds: ['remote-repo', 'stale-repo'] + }) + + await store.getState().fetchRepos() + + expect(store.getState().repos).toEqual([remoteRepo]) + expect(store.getState().activeRepoId).toBeNull() + expect(store.getState().filterRepoIds).toEqual(['remote-repo']) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.list', + params: undefined, + timeoutMs: 15_000 + }) + expect(reposList).not.toHaveBeenCalled() + }) + + it('updates repos through the active remote runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-2', + ok: true, + result: { repo: { ...remoteRepo, displayName: 'Renamed' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [remoteRepo] + }) + + await store.getState().updateRepo(remoteRepo.id, { displayName: 'Renamed' }) + + expect(store.getState().repos[0]?.displayName).toBe('Renamed') + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.update', + params: { repo: remoteRepo.id, updates: { displayName: 'Renamed' } }, + timeoutMs: 15_000 + }) + expect(reposUpdate).not.toHaveBeenCalled() + }) + + it('adds explicit server paths through the active remote runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-add', + ok: true, + result: { repo: remoteRepo }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never + }) + + await expect(store.getState().addRepoPath('/srv/project', 'folder')).resolves.toEqual( + remoteRepo + ) + + expect(store.getState().repos).toEqual([remoteRepo]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.add', + params: { path: '/srv/project', kind: 'folder' }, + timeoutMs: 15_000 + }) + expect(reposAdd).not.toHaveBeenCalled() + expect(reposPickFolder).not.toHaveBeenCalled() + }) + + it('does not open the client folder picker when a remote runtime environment is active', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never + }) + + await expect(store.getState().addRepo()).resolves.toBeNull() + + expect(reposPickFolder).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('removes repos through the active remote runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-3', + ok: true, + result: { removed: true }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [remoteRepo], + activeRepoId: remoteRepo.id + }) + + await store.getState().removeRepo(remoteRepo.id) + + expect(store.getState().repos).toEqual([]) + expect(store.getState().activeRepoId).toBeNull() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.rm', + params: { repo: remoteRepo.id }, + timeoutMs: 15_000 + }) + expect(reposRemove).not.toHaveBeenCalled() + }) + + it('stops remote runtime terminals instead of killing remote ids through local pty IPC', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-remote', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + const worktreeId = `${remoteRepo.id}::/remote/wt` + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [remoteRepo], + worktreesByRepo: { + [remoteRepo.id]: [makeWorktree({ id: worktreeId, repoId: remoteRepo.id })] + }, + tabsByWorktree: { + [worktreeId]: [{ id: 'tab-1', worktreeId } as never] + }, + ptyIdsByTabId: { + 'tab-1': ['remote:term-1', 'pty-local-stale'] + } + }) + + await store.getState().removeRepo(remoteRepo.id) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.stop', + params: { worktree: worktreeId }, + timeoutMs: 15_000 + }) + expect(ptyKill).toHaveBeenCalledWith('pty-local-stale') + expect(ptyKill).not.toHaveBeenCalledWith('remote:term-1') + }) + + it('reorders repos through the active remote runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-4', + ok: true, + result: { status: 'applied' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localRepo, remoteRepo] + }) + + await store.getState().reorderRepos([remoteRepo.id, localRepo.id]) + + expect(store.getState().repos.map((repo) => repo.id)).toEqual([remoteRepo.id, localRepo.id]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.reorder', + params: { orderedIds: [remoteRepo.id, localRepo.id] }, + timeoutMs: 15_000 + }) + expect(reposReorder).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 78977a17b7d..0cc50b4bc84 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -1,9 +1,14 @@ +/* eslint-disable max-lines -- Why: repo slice owns local/runtime routing, +add/remove/reorder side effects, and cross-slice teardown. Splitting it during +the client-server refactor would obscure the invariants this file is currently +auditing and preserving. */ import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' import type { Repo } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getRepoIdFromWorktreeId } from './worktree-helpers' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' const ERROR_TOAST_DURATION = 60_000 @@ -12,6 +17,7 @@ export type RepoSlice = { activeRepoId: string | null fetchRepos: () => Promise addRepo: () => Promise + addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise addNonGitFolder: (path: string) => Promise removeRepo: (repoId: string) => Promise updateRepo: ( @@ -39,7 +45,21 @@ export const createRepoSlice: StateCreator = (set, fetchRepos: async () => { try { - const repos = await window.api.repos.list() + const target = getActiveRuntimeTarget(get().settings) + const repos = + target.kind === 'local' + ? ((await window.api.repos.list()) as Repo[]) + : ( + await callRuntimeRpc<{ repos: Repo[] }>( + target, + 'repo.list', + undefined, + // Why: remote environment fetches cross the network; keep the + // boot-time repo hydration bounded instead of inheriting an + // unbounded renderer promise. + { timeoutMs: 15_000 } + ) + ).repos set((s) => { const validRepoIds = new Set(repos.map((repo) => repo.id)) return { @@ -53,22 +73,30 @@ export const createRepoSlice: StateCreator = (set, } }, - addRepo: async () => { + addRepoPath: async (path, kind = 'git') => { try { - const path = await window.api.repos.pickFolder() - if (!path) { - return null - } + const target = getActiveRuntimeTarget(get().settings) let repo: Repo try { - const result = await window.api.repos.add({ path }) - if ('error' in result) { - throw new Error(result.error) + if (target.kind === 'local') { + const result = await window.api.repos.add({ path, kind }) + if ('error' in result) { + throw new Error(result.error) + } + repo = result.repo + } else { + repo = ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.add', + { path, kind }, + { timeoutMs: 15_000 } + ) + ).repo } - repo = result.repo } catch (err) { const message = err instanceof Error ? err.message : String(err) - if (!message.includes('Not a valid git repository')) { + if (kind !== 'git' || !message.includes('Not a valid git repository')) { throw err } // Why: folder mode is a capability downgrade, not a silent fallback. @@ -109,27 +137,26 @@ export const createRepoSlice: StateCreator = (set, } }, + addRepo: async () => { + const target = getActiveRuntimeTarget(get().settings) + if (target.kind !== 'local') { + // Why: OS folder pickers return client-local paths. Remote environments + // need an explicit server path, which the Add Project dialog handles. + toast.error('Use a server path to add projects from a remote runtime.') + return null + } + const path = await window.api.repos.pickFolder() + if (!path) { + return null + } + return get().addRepoPath(path) + }, + addNonGitFolder: async (path) => { try { - const result = await window.api.repos.add({ path, kind: 'folder' }) - if ('error' in result) { - throw new Error(result.error) - } - const repo = result.repo - const alreadyAdded = get().repos.some((r) => r.id === repo.id) - if (alreadyAdded) { - get().clearOrcaHookTrustForRepo(repo.id) - } - set((s) => { - if (s.repos.some((r) => r.id === repo.id)) { - return s - } - return { repos: [...s.repos, repo] } - }) - if (alreadyAdded) { - toast.info('Project already added', { description: repo.displayName }) - } else { - toast.success('Folder added', { description: repo.displayName }) + const repo = await get().addRepoPath(path, 'folder') + if (!repo) { + return null } // Why: without focusing the new folder, the UI looks unchanged after // the dialog closes and users think nothing happened. Fetch the @@ -154,7 +181,10 @@ export const createRepoSlice: StateCreator = (set, removeRepo: async (repoId) => { try { - await window.api.repos.remove({ repoId }) + const target = getActiveRuntimeTarget(get().settings) + await (target.kind === 'local' + ? window.api.repos.remove({ repoId }) + : callRuntimeRpc(target, 'repo.rm', { repo: repoId }, { timeoutMs: 15_000 })) get().clearOrcaHookTrustForRepo(repoId) @@ -162,13 +192,22 @@ export const createRepoSlice: StateCreator = (set, const worktreeIds = (get().worktreesByRepo[repoId] ?? []).map((w) => w.id) const killedTabIds = new Set() const killedPtyIds = new Set() + if (target.kind === 'environment') { + await Promise.allSettled( + worktreeIds.map((worktreeId) => + callRuntimeRpc(target, 'terminal.stop', { worktree: worktreeId }, { timeoutMs: 15_000 }) + ) + ) + } for (const wId of worktreeIds) { const tabs = get().tabsByWorktree[wId] ?? [] for (const tab of tabs) { killedTabIds.add(tab.id) for (const ptyId of get().ptyIdsByTabId[tab.id] ?? []) { killedPtyIds.add(ptyId) - window.api.pty.kill(ptyId) + if (!ptyId.startsWith('remote:')) { + window.api.pty.kill(ptyId) + } } } } @@ -259,7 +298,10 @@ export const createRepoSlice: StateCreator = (set, updateRepo: async (repoId, updates) => { try { - await window.api.repos.update({ repoId, updates }) + const target = getActiveRuntimeTarget(get().settings) + await (target.kind === 'local' + ? window.api.repos.update({ repoId, updates }) + : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) set((s) => ({ repos: s.repos.map((r) => (r.id === repoId ? { ...r, ...updates } : r)) })) @@ -288,7 +330,16 @@ export const createRepoSlice: StateCreator = (set, } set({ repos: next }) try { - const result = await window.api.repos.reorder({ orderedIds }) + const target = getActiveRuntimeTarget(get().settings) + const result = + target.kind === 'local' + ? await window.api.repos.reorder({ orderedIds }) + : await callRuntimeRpc<{ status: 'applied' | 'rejected' }>( + target, + 'repo.reorder', + { orderedIds }, + { timeoutMs: 15_000 } + ) if (result.status === 'rejected') { await get().fetchRepos() } diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts new file mode 100644 index 00000000000..9d0bc5e5476 --- /dev/null +++ b/src/renderer/src/store/slices/settings.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +import type { AppState } from '../types' +import { toast } from 'sonner' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) + } +}) + +const runtimeEnvironmentCall = vi.fn() +const settingsSet = vi.fn().mockResolvedValue(undefined) + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'runtime-2', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : method === 'repo.list' + ? { + repos: [ + { + id: 'repo-env-2', + path: '/env-2/repo', + displayName: 'Env 2', + badgeColor: 'blue', + addedAt: 1 + } + ] + } + : method === 'worktree.list' + ? { + worktrees: [ + makeWorktree({ + id: 'repo-env-2::/env-2/repo', + repoId: 'repo-env-2', + path: '/env-2/repo' + }) + ], + totalCount: 1, + truncated: false + } + : method === 'browser.profile.list' + ? { profiles: [] } + : {} + return Promise.resolve({ id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-2' } }) + }) + vi.stubGlobal('window', { + api: { + settings: { set: settingsSet }, + runtimeEnvironments: { call: runtimeEnvironmentCall } + } + }) +}) + +describe('createSettingsSlice runtime switching', () => { + it('clears stale runtime-owned state before loading the selected environment', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never], + worktreesByRepo: { + 'repo-env-1': [makeWorktree({ id: 'repo-env-1::/env-1/repo', repoId: 'repo-env-1' })] + }, + activeWorktreeId: 'repo-env-1::/env-1/repo', + openFiles: [{ id: '/env-1/repo/a.md', worktreeId: 'repo-env-1::/env-1/repo' } as never], + ptyIdsByTabId: { tab1: ['remote:env-1@@terminal-a'] }, + terminalLayoutsByTabId: { + tab1: { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote:legacy-terminal' } + } + }, + browserTabsByWorktree: { 'repo-env-1::/env-1/repo': [{ id: 'browser-env-1' }] as never }, + browserPagesByWorkspace: { + 'browser-env-1': [{ id: 'page-env-1', worktreeId: 'repo-env-1::/env-1/repo' }] as never + }, + remoteBrowserPageHandlesByPageId: { + 'page-env-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + }, + editorDrafts: { '/env-1/repo/stale.md': 'stale' }, + markdownViewMode: { '/env-1/repo/stale.md': 'rich' }, + editorViewMode: { '/env-1/repo/stale.md': 'changes' }, + editorCursorLine: { '/env-1/repo/stale.md': 4 }, + prCache: { '/env-1/repo::main': { data: null, fetchedAt: Date.now() } }, + linearIssueCache: { 'LIN-1': { data: { id: 'LIN-1' } as never, fetchedAt: Date.now() } } + }) + + await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) + + expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'status.get' }) + ) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'repo.list' }) + ) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'terminal-a' } + }) + ) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'legacy-terminal' } + }) + ) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'browser.tabClose', + params: { worktree: 'id:repo-env-1::/env-1/repo', page: 'remote-page-1' } + }) + ) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['repo-env-2']) + expect(store.getState().worktreesByRepo['repo-env-2']?.map((worktree) => worktree.id)).toEqual([ + 'repo-env-2::/env-2/repo' + ]) + expect(store.getState().activeWorktreeId).toBeNull() + expect(store.getState().openFiles).toEqual([]) + expect(store.getState().editorDrafts).toEqual({}) + expect(store.getState().markdownViewMode).toEqual({}) + expect(store.getState().editorViewMode).toEqual({}) + expect(store.getState().editorCursorLine).toEqual({}) + expect(store.getState().ptyIdsByTabId).toEqual({}) + expect(store.getState().browserTabsByWorktree).toEqual({}) + expect(store.getState().prCache).toEqual({}) + expect(store.getState().linearIssueCache).toEqual({}) + }) + + it('refuses to switch environments while editor tabs have unsaved state', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + openFiles: [ + { + id: '/env-1/repo/dirty.md', + worktreeId: 'repo-env-1::/env-1/repo', + isDirty: true + } as never + ], + editorDrafts: { '/env-1/repo/dirty.md': 'draft' } + }) + + await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(false) + + expect(settingsSet).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().settings?.activeRuntimeEnvironmentId).toBe('env-1') + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().editorDrafts).toEqual({ '/env-1/repo/dirty.md': 'draft' }) + expect(toast.error).toHaveBeenCalledWith( + 'Save or close unsaved editor tabs before switching servers.' + ) + }) + + it('keeps the current environment when the selected remote server is unreachable', async () => { + runtimeEnvironmentCall.mockRejectedValueOnce( + new Error('Remote Orca runtime closed the connection.') + ) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never], + openFiles: [], + ptyIdsByTabId: { tab1: ['remote:env-1@@terminal-a'] } + }) + + await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(false) + + expect(settingsSet).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'status.get' }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'terminal.close' }) + ) + expect(store.getState().settings?.activeRuntimeEnvironmentId).toBe('env-1') + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['repo-env-1']) + expect(store.getState().ptyIdsByTabId).toEqual({ tab1: ['remote:env-1@@terminal-a'] }) + expect(toast.error).toHaveBeenCalledWith('Failed to switch servers', { + description: 'Remote Orca runtime closed the connection.' + }) + }) + + it('keeps the current environment when the selected server is protocol-incompatible', async () => { + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'runtime-old', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION - 1, + minCompatibleRuntimeClientVersion: 0 + } + : {} + return Promise.resolve({ + id: 'rpc-1', + ok: true, + result, + _meta: { runtimeId: 'runtime-old' } + }) + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never], + openFiles: [] + }) + + await expect(store.getState().switchRuntimeEnvironment('env-old')).resolves.toBe(false) + + expect(settingsSet).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-old', method: 'status.get' }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-old', method: 'repo.list' }) + ) + expect(toast.error).toHaveBeenCalledWith('Failed to switch servers', { + description: expect.stringContaining('server is too old') + }) + }) +}) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 8307e594667..eb11cbf64f6 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -1,6 +1,12 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { GlobalSettings } from '../../../../shared/types' +import { toast } from 'sonner' +import { callRuntimeRpc, clearRuntimeCompatibilityCache } from '@/runtime/runtime-rpc-client' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle +} from '@/runtime/runtime-terminal-stream' import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quick-commands' export type SettingsSlice = { @@ -9,9 +15,199 @@ export type SettingsSlice = { setSettingsSearchQuery: (q: string) => void fetchSettings: () => Promise updateSettings: (updates: Partial) => Promise + switchRuntimeEnvironment: (environmentId: string | null) => Promise } -export const createSettingsSlice: StateCreator = (set) => ({ +function normalizeRuntimeEnvironmentId(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function runtimeScopedStateReset(): Partial { + return { + repos: [], + activeRepoId: null, + sparsePresetsByRepo: {}, + sparsePresetsLoadingByRepo: {}, + sparsePresetsLoadStatusByRepo: {}, + sparsePresetsErrorByRepo: {}, + worktreesByRepo: {}, + activeWorktreeId: null, + deleteStateByWorktreeId: {}, + baseStatusByWorktreeId: {}, + remoteBranchConflictByWorktreeId: {}, + sortEpoch: 0, + everActivatedWorktreeIds: new Set(), + lastVisitedAtByWorktreeId: {}, + hasHydratedWorktreePurge: false, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + layoutByWorktree: {}, + tabsByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + unreadTerminalTabs: {}, + suppressedPtyExitIds: {}, + pendingCodexPaneRestartIds: {}, + codexRestartNoticeByPtyId: {}, + expandedPaneByTabId: {}, + canExpandPaneByTabId: {}, + terminalLayoutsByTabId: {}, + pendingStartupByTabId: {}, + pendingSetupSplitByTabId: {}, + pendingIssueCommandSplitByTabId: {}, + tabBarOrderByWorktree: {}, + pendingReconnectWorktreeIds: [], + pendingReconnectTabByWorktree: {}, + pendingReconnectPtyIdByTabId: {}, + lastKnownRelayPtyIdByTabId: {}, + pendingSnapshotByPtyId: {}, + pendingColdRestoreByPtyId: {}, + deferredSshReconnectTargets: [], + deferredSshSessionIdsByTabId: {}, + cacheTimerByKey: {}, + expandedDirs: {}, + pendingExplorerReveal: null, + openFiles: [], + editorDrafts: {}, + markdownViewMode: {}, + editorViewMode: {}, + editorCursorLine: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabType: 'terminal', + recentlyClosedEditorTabsByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + remoteBrowserPageHandlesByPageId: {}, + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + recentlyClosedBrowserTabsByWorktree: {}, + recentlyClosedBrowserPagesByWorkspace: {}, + pendingAddressBarFocusByTabId: {}, + pendingAddressBarFocusByPageId: {}, + browserSessionProfiles: [], + browserSessionImportState: null, + defaultBrowserSessionProfileId: null, + detectedBrowsers: [], + detectedBrowsersLoaded: false, + prCache: {}, + issueCache: {}, + checksCache: {}, + commentsCache: {}, + workItemsCache: {}, + workItemsInvalidationNonce: 0, + projectViewCache: {}, + linearStatus: { connected: false, viewer: null }, + linearStatusChecked: false, + linearIssueCache: {}, + linearSearchCache: {} + } +} + +function hasUnsavedEditorState(state: AppState): boolean { + return state.openFiles.some((file) => file.isDirty || state.editorDrafts[file.id] !== undefined) +} + +async function closeRemoteBrowserPagesBeforeRuntimeSwitch(state: AppState): Promise { + const worktreeIdByPageId = new Map() + for (const pages of Object.values(state.browserPagesByWorkspace)) { + for (const page of pages) { + worktreeIdByPageId.set(page.id, page.worktreeId) + } + } + await Promise.allSettled( + Object.entries(state.remoteBrowserPageHandlesByPageId).map(([pageId, handle]) => { + const worktreeId = worktreeIdByPageId.get(pageId) + if (!worktreeId) { + return Promise.resolve() + } + return callRuntimeRpc( + { kind: 'environment', environmentId: handle.environmentId }, + 'browser.tabClose', + { worktree: `id:${worktreeId}`, page: handle.remotePageId }, + { timeoutMs: 15_000 } + ) + }) + ) +} + +function collectRemoteTerminalHandlesForRuntimeSwitch( + state: AppState, + fallbackEnvironmentId: string | null +): Map> { + const handlesByEnvironmentId = new Map>() + const collect = (ptyId: string | null | undefined): void => { + if (!ptyId) { + return + } + const handle = getRemoteRuntimeTerminalHandle(ptyId) + if (!handle) { + return + } + const environmentId = getRemoteRuntimePtyEnvironmentId(ptyId) ?? fallbackEnvironmentId + if (!environmentId) { + return + } + const handles = handlesByEnvironmentId.get(environmentId) ?? new Set() + handles.add(handle) + handlesByEnvironmentId.set(environmentId, handles) + } + + for (const ptyIds of Object.values(state.ptyIdsByTabId)) { + for (const ptyId of ptyIds) { + collect(ptyId) + } + } + for (const tabs of Object.values(state.tabsByWorktree)) { + for (const tab of tabs) { + collect(tab.ptyId) + } + } + for (const layout of Object.values(state.terminalLayoutsByTabId)) { + for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { + collect(ptyId) + } + } + return handlesByEnvironmentId +} + +async function closeRemoteTerminalsBeforeRuntimeSwitch( + state: AppState, + fallbackEnvironmentId: string | null +): Promise { + const handlesByEnvironmentId = collectRemoteTerminalHandlesForRuntimeSwitch( + state, + fallbackEnvironmentId + ) + await Promise.allSettled( + Array.from(handlesByEnvironmentId.entries()).flatMap(([environmentId, handles]) => + Array.from(handles).map((terminal) => + callRuntimeRpc( + { kind: 'environment', environmentId }, + 'terminal.close', + { terminal }, + { timeoutMs: 15_000 } + ) + ) + ) + ) +} + +async function verifyRuntimeEnvironmentReachable(environmentId: string | null): Promise { + if (!environmentId) { + return + } + await callRuntimeRpc({ kind: 'environment', environmentId }, 'repo.list', undefined, { + timeoutMs: 15_000 + }) +} + +export const createSettingsSlice: StateCreator = (set, get) => ({ settings: null, settingsSearchQuery: '', setSettingsSearchQuery: (q) => set({ settingsSearchQuery: q }), @@ -72,5 +268,43 @@ export const createSettingsSlice: StateCreator } catch (err) { console.error('Failed to update settings:', err) } + }, + + switchRuntimeEnvironment: async (environmentId) => { + const nextId = normalizeRuntimeEnvironmentId(environmentId) + const previousId = normalizeRuntimeEnvironmentId(get().settings?.activeRuntimeEnvironmentId) + if (previousId === nextId) { + return true + } + if (hasUnsavedEditorState(get())) { + toast.error('Save or close unsaved editor tabs before switching servers.') + return false + } + try { + clearRuntimeCompatibilityCache(nextId) + await verifyRuntimeEnvironmentReachable(nextId) + // Why: remote browser tabs live on their owning server. Close them before + // clearing browser maps so the old server does not retain orphan pages. + await closeRemoteTerminalsBeforeRuntimeSwitch(get(), previousId) + await closeRemoteBrowserPagesBeforeRuntimeSwitch(get()) + await window.api.settings.set({ activeRuntimeEnvironmentId: nextId }) + set((s) => ({ + ...runtimeScopedStateReset(), + settings: s.settings ? { ...s.settings, activeRuntimeEnvironmentId: nextId } : null + })) + // Why: server-owned state is cleared before refetch so old worktree, + // terminal, browser, and issue IDs cannot be used against the new server + // while the new environment is loading. + await get().fetchRepos() + await get().fetchAllWorktrees() + await get().fetchBrowserSessionProfiles() + return true + } catch (err) { + console.error('Failed to switch runtime environment:', err) + toast.error('Failed to switch servers', { + description: err instanceof Error ? err.message : String(err) + }) + return false + } } }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 662ed2e87ba..e526fd41885 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -28,6 +28,7 @@ import { unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' function getNextTerminalOrdinal(tabs: TerminalTab[]): number { const usedOrdinals = new Set() @@ -1252,8 +1253,20 @@ export const createTerminalSlice: StateCreator return } + const target = getActiveRuntimeTarget(get().settings) + if (target.kind === 'environment') { + await callRuntimeRpc( + target, + 'terminal.stop', + { worktree: worktreeId }, + { timeoutMs: 15_000 } + ).catch(() => null) + } + await Promise.allSettled( - ptyIds.map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) + ptyIds + .filter((ptyId) => !ptyId.startsWith('remote:')) + .map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) ) }, diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 8fce0f6cb15..f9de8d4140b 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -259,6 +259,7 @@ export type UISlice = { | 'accounts' | 'voice' | 'experimental' + | 'servers' | 'mobile' | 'ssh' repoId: string | null diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index d884ff07460..b5517e65649 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -6,6 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' import type { Worktree } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() const mockApi = { worktrees: { @@ -19,6 +27,9 @@ const mockApi = { }, hooks: { check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false }) + }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall } } @@ -27,6 +38,15 @@ globalThis.window = { api: mockApi } import { createWorktreeSlice } from './worktrees' +function resetRemoteRuntimeMocks() { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) +} + function createTestStore() { return create()( (...a) => @@ -98,6 +118,7 @@ function makeWorktree(overrides: Partial & { id: string; repoId: strin describe('fetchWorktrees', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('does not notify subscribers when the fetched payload is unchanged', async () => { @@ -192,11 +213,40 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo.repo1).toEqual([]) expect(store.getState().sortEpoch).toBe(8) }) + + it('fetches worktrees from the active remote runtime environment', async () => { + const store = createTestStore() + const remote = makeWorktree({ + id: 'repo1::/remote/wt1', + repoId: 'repo1', + path: '/remote/wt1', + branch: 'refs/heads/remote' + }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { worktrees: [remote], totalCount: 1, truncated: false }, + _meta: { runtimeId: 'runtime-remote' } + }) + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1).toEqual([remote]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.list', + params: { repo: 'repo1' }, + timeoutMs: 15_000 + }) + expect(mockApi.worktrees.list).not.toHaveBeenCalled() + }) }) describe('updateWorktreeGitIdentity', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('updates branch identity from git status without fetching worktrees', () => { @@ -228,6 +278,7 @@ describe('updateWorktreeGitIdentity', () => { describe('createWorktree base status merge', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('passes linked work item and creation agent metadata through the create IPC payload', async () => { @@ -311,6 +362,7 @@ describe('createWorktree base status merge', () => { describe('removeWorktree state cleanup', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('cleans up editorDrafts for files in the removed worktree', async () => { @@ -609,6 +661,120 @@ describe('removeWorktree state cleanup', () => { }) }) +describe('worktree remote runtime mutations', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('creates worktrees through the active remote runtime environment', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/feature', + repoId: 'repo1', + path: '/path/feature' + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-create', + ok: true, + result: { worktree: wt }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [] } + } as Partial) + + const result = await store + .getState() + .createWorktree( + 'repo1', + 'feature', + 'origin/main', + 'skip', + { directories: ['src'], presetId: 'preset-1' }, + 'sidebar', + 'Feature title', + 123, + 456, + { remoteName: 'fork', branchName: 'feature' } + ) + + expect(result).toEqual({ worktree: wt }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.create', + params: { + repo: 'repo1', + name: 'feature', + baseBranch: 'origin/main', + setupDecision: 'skip', + sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, + displayName: 'Feature title', + linkedIssue: 123, + linkedPR: 456, + pushTarget: { remoteName: 'fork', branchName: 'feature' } + }, + timeoutMs: 10 * 60_000 + }) + expect(mockApi.worktrees.create).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo.repo1).toEqual([wt]) + }) + + it('removes worktrees through the active remote runtime environment', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-rm', + ok: true, + result: { removed: true }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [wt] } + } as Partial) + + const result = await store.getState().removeWorktree(wt.id) + + expect(result).toEqual({ ok: true }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.rm', + params: { worktree: wt.id, force: undefined, runHooks: true }, + timeoutMs: 60_000 + }) + expect(mockApi.worktrees.remove).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo.repo1).toEqual([]) + }) + + it('persists worktree metadata through the active remote runtime environment', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-set', + ok: true, + result: { worktree: { ...wt, comment: 'remote note' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [wt] } + } as Partial) + + await store.getState().updateWorktreeMeta(wt.id, { comment: 'remote note' }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.set', + params: expect.objectContaining({ worktree: wt.id, comment: 'remote note' }), + timeoutMs: 15_000 + }) + expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo.repo1[0]?.comment).toBe('remote note') + }) +}) + // Why: ghostty "show until interact" model — BEL must raise the sidebar dot // even on the active worktree, and only clearWorktreeUnread (called from the // terminal pane on keystroke / pointerdown) dismisses it. Pins both halves @@ -616,6 +782,7 @@ describe('removeWorktree state cleanup', () => { describe('worktree unread (show-until-interact)', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('markWorktreeUnread sets isUnread even when the worktree is active', async () => { @@ -685,6 +852,7 @@ describe('worktree unread (show-until-interact)', () => { describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) const repoA = { @@ -823,6 +991,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { describe('purgeWorktreeTerminalState direct (design §4.4)', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('wipes tab-id-keyed maps (terminalLayoutsByTabId, ptyIdsByTabId) and clears actives', () => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index d804e49c475..9d738dea8c6 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -10,6 +10,7 @@ import { } from './worktree-helpers' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean { @@ -68,6 +69,43 @@ function toVisibleTabType(contentType: string): WorkspaceVisibleTabType { : 'editor' } +async function listWorktreesForRepo( + settings: AppState['settings'], + repoId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + return window.api.worktrees.list({ repoId }) + } + const result = await callRuntimeRpc<{ worktrees: Worktree[] }>( + target, + 'worktree.list', + { repo: repoId }, + // Why: remote environment hydration crosses the network. Bound the call + // so startup can recover instead of leaving the renderer waiting forever. + { timeoutMs: 15_000 } + ) + return result.worktrees +} + +async function persistWorktreeMeta( + settings: AppState['settings'], + worktreeId: string, + updates: Partial +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + await window.api.worktrees.updateMeta({ worktreeId, updates }) + return + } + await callRuntimeRpc( + target, + 'worktree.set', + { worktree: worktreeId, ...updates }, + { timeoutMs: 15_000 } + ) +} + export const createWorktreeSlice: StateCreator = (set, get) => ({ worktreesByRepo: {}, activeWorktreeId: null, @@ -81,7 +119,7 @@ export const createWorktreeSlice: StateCreator fetchWorktrees: async (repoId) => { try { - const worktrees = await window.api.worktrees.list({ repoId }) + const worktrees = await listWorktreesForRepo(get().settings, repoId) const current = get().worktreesByRepo[repoId] if (areWorktreesEqual(current, worktrees)) { return @@ -135,7 +173,7 @@ export const createWorktreeSlice: StateCreator const results = await Promise.all( repos.map(async (r) => { try { - const list = await window.api.worktrees.list({ repoId: r.id }) + const list = await listWorktreesForRepo(get().settings, r.id) const current = get().worktreesByRepo[r.id] if ( !areWorktreesEqual(current, list) && @@ -252,7 +290,7 @@ export const createWorktreeSlice: StateCreator for (let attempt = 0; attempt < 25; attempt += 1) { const candidateName = nextCandidateName(name, attempt) try { - const result = await window.api.worktrees.create({ + const createArgs = { repoId, name: candidateName, baseBranch, @@ -264,7 +302,28 @@ export const createWorktreeSlice: StateCreator ...(linkedPR !== undefined ? { linkedPR } : {}), ...(pushTarget ? { pushTarget } : {}), ...(createdWithAgent ? { createdWithAgent } : {}) - }) + } + const target = getActiveRuntimeTarget(get().settings) + const result = + target.kind === 'local' + ? await window.api.worktrees.create(createArgs) + : await callRuntimeRpc>>( + target, + 'worktree.create', + { + repo: repoId, + name: candidateName, + baseBranch, + setupDecision, + sparseCheckout, + ...(displayName ? { displayName } : {}), + ...(linkedIssue !== undefined ? { linkedIssue } : {}), + ...(linkedPR !== undefined ? { linkedPR } : {}), + ...(pushTarget ? { pushTarget } : {}), + ...(createdWithAgent ? { createdWithAgent } : {}) + }, + { timeoutMs: 10 * 60_000 } + ) // Why: a file watcher (worktrees.onChanged) can fire between the // backend creating the worktree and this callback running, causing // fetchWorktrees to add the worktree first. Appending unconditionally @@ -338,7 +397,15 @@ export const createWorktreeSlice: StateCreator // can intercept them. await get().shutdownWorktreeBrowsers(worktreeId) await get().shutdownWorktreeTerminals(worktreeId) - await window.api.worktrees.remove({ worktreeId, force, skipArchive }) + const target = getActiveRuntimeTarget(get().settings) + await (target.kind === 'local' + ? window.api.worktrees.remove({ worktreeId, force, skipArchive }) + : callRuntimeRpc( + target, + 'worktree.rm', + { worktree: worktreeId, force, runHooks: !skipArchive }, + { timeoutMs: 60_000 } + )) const tabs = get().tabsByWorktree[worktreeId] ?? [] const tabIds = new Set(tabs.map((t) => t.id)) @@ -557,7 +624,7 @@ export const createWorktreeSlice: StateCreator }) try { - await window.api.worktrees.updateMeta({ worktreeId, updates: enriched }) + await persistWorktreeMeta(get().settings, worktreeId, enriched) } catch (err) { console.error('Failed to update worktree meta:', err) void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) @@ -591,12 +658,13 @@ export const createWorktreeSlice: StateCreator return } - void window.api.worktrees - .updateMeta({ worktreeId, updates: { isUnread: true, lastActivityAt: now } }) - .catch((err) => { - console.error('Failed to persist unread worktree state:', err) - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - }) + void persistWorktreeMeta(get().settings, worktreeId, { + isUnread: true, + lastActivityAt: now + }).catch((err) => { + console.error('Failed to persist unread worktree state:', err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + }) }, clearWorktreeUnread: (worktreeId) => { @@ -622,12 +690,10 @@ export const createWorktreeSlice: StateCreator return } - void window.api.worktrees - .updateMeta({ worktreeId, updates: { isUnread: false } }) - .catch((err) => { - console.error('Failed to persist cleared unread worktree state:', err) - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - }) + void persistWorktreeMeta(get().settings, worktreeId, { isUnread: false }).catch((err) => { + console.error('Failed to persist cleared unread worktree state:', err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + }) }, bumpWorktreeActivity: (worktreeId) => { @@ -655,12 +721,10 @@ export const createWorktreeSlice: StateCreator } }) - void window.api.worktrees - .updateMeta({ worktreeId, updates: { lastActivityAt: now } }) - .catch((err) => { - console.error('Failed to persist worktree activity timestamp:', err) - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - }) + void persistWorktreeMeta(get().settings, worktreeId, { lastActivityAt: now }).catch((err) => { + console.error('Failed to persist worktree activity timestamp:', err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + }) }, markWorktreeVisited: (worktreeId, visitedAt) => { @@ -947,11 +1011,11 @@ export const createWorktreeSlice: StateCreator } if (shouldClearUnread) { - const updates: Parameters[0]['updates'] = { + const updates: Partial = { isUnread: false } - void window.api.worktrees.updateMeta({ worktreeId, updates }).catch((err) => { + void persistWorktreeMeta(get().settings, worktreeId, updates).catch((err) => { console.error('Failed to persist worktree activation state:', err) void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 68560894056..6f6f33fa764 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -251,6 +251,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { experimentalPet: false, experimentalActivity: true, experimentalWorktreeSymlinks: false, + // Why: local desktop remains the default server until the user explicitly + // selects a saved runtime environment. + activeRuntimeEnvironmentId: null, // Why: hydrate an empty default so the renderer's optional-chained reads // (`settings?.githubProjects?.activeProject`) land on a stable shape // instead of `undefined`. Upgraded profiles inherit this via the diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts new file mode 100644 index 00000000000..ea805b7a788 --- /dev/null +++ b/src/shared/cross-platform-path.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { isPathInsideOrEqual, relativePathInsideRoot } from './cross-platform-path' + +describe('cross-platform path containment', () => { + it('keeps POSIX sibling prefixes outside the root', () => { + expect(isPathInsideOrEqual('/repo/app', '/repo/app')).toBe(true) + expect(isPathInsideOrEqual('/repo/app', '/repo/app/src/index.ts')).toBe(true) + expect(isPathInsideOrEqual('/repo/app', '/repo/application/src/index.ts')).toBe(false) + expect(relativePathInsideRoot('/repo/app/', '/repo/app/src/index.ts')).toBe('src/index.ts') + }) + + it('handles Windows drive roots and sibling drives case-insensitively', () => { + expect(isPathInsideOrEqual('C:\\Repo', 'c:\\repo\\src\\index.ts')).toBe(true) + expect(relativePathInsideRoot('C:\\Repo', 'c:\\repo\\src\\index.ts')).toBe('src/index.ts') + expect(isPathInsideOrEqual('C:\\Repo', 'D:\\Repo\\src\\index.ts')).toBe(false) + expect(relativePathInsideRoot('C:\\', 'c:\\repo\\src\\index.ts')).toBe('repo/src/index.ts') + }) + + it('handles UNC roots, trailing slashes, mixed separators, and case', () => { + expect(isPathInsideOrEqual('\\\\Server\\Share\\Repo\\', '//server/share/repo/src')).toBe(true) + expect(relativePathInsideRoot('\\\\Server\\Share\\Repo\\', '//server/share/repo/src')).toBe( + 'src' + ) + expect(isPathInsideOrEqual('\\\\Server\\Share\\Repo', '\\\\server\\share\\repo2')).toBe(false) + }) +}) diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts new file mode 100644 index 00000000000..d2645a34239 --- /dev/null +++ b/src/shared/cross-platform-path.ts @@ -0,0 +1,57 @@ +export function isWindowsAbsolutePathLike(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//') +} + +export function normalizeRuntimePathSeparators(value: string): string { + const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/') + if (value.startsWith('\\\\') || value.startsWith('//')) { + return `//${normalized.replace(/^\/+/, '')}` + } + return normalized +} + +export function normalizeRuntimePathForComparison(value: string): string { + const normalized = trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(value)) + return isWindowsAbsolutePathLike(value) ? normalized.toLowerCase() : normalized +} + +export function isPathInsideOrEqual(rootPath: string, candidatePath: string): boolean { + const root = normalizeRuntimePathForComparison(rootPath) + const candidate = normalizeRuntimePathForComparison(candidatePath) + if (candidate === root) { + return true + } + const rootWithBoundary = + root === '/' || /^[a-z]:\/$/i.test(root) ? root : `${root.replace(/\/+$/, '')}/` + return candidate.startsWith(rootWithBoundary) +} + +export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null { + const normalizedRoot = trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(rootPath)) + const normalizedCandidate = trimRuntimePathTrailingSlash( + normalizeRuntimePathSeparators(candidatePath) + ) + const comparisonRoot = isWindowsAbsolutePathLike(rootPath) + ? normalizedRoot.toLowerCase() + : normalizedRoot + const comparisonCandidate = isWindowsAbsolutePathLike(rootPath) + ? normalizedCandidate.toLowerCase() + : normalizedCandidate + + if (comparisonCandidate === comparisonRoot) { + return '' + } + const isRoot = comparisonRoot === '/' || /^[a-z]:\/$/i.test(comparisonRoot) + const comparisonPrefix = isRoot ? comparisonRoot : `${comparisonRoot}/` + if (!comparisonCandidate.startsWith(comparisonPrefix)) { + return null + } + return normalizedCandidate.slice(comparisonPrefix.length) +} + +function trimRuntimePathTrailingSlash(value: string): string { + if (value === '/' || /^[A-Za-z]:\/$/.test(value)) { + return value + } + return value.replace(/\/+$/, '') +} diff --git a/src/shared/e2ee-crypto.ts b/src/shared/e2ee-crypto.ts new file mode 100644 index 00000000000..3d5bcdb5317 --- /dev/null +++ b/src/shared/e2ee-crypto.ts @@ -0,0 +1,65 @@ +// Why: Orca's remote runtime transports share one NaCl box format across +// desktop, CLI, and mobile pairing. Keeping the Node-compatible primitives in +// shared code prevents the CLI from importing main-process modules. +import nacl from 'tweetnacl' + +export function generateKeyPair(): nacl.BoxKeyPair { + return nacl.box.keyPair() +} + +export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return nacl.box.before(peerPublicKey, ourSecretKey) +} + +export function publicKeyFromBase64(b64: string): Uint8Array { + const key = Uint8Array.from(Buffer.from(b64, 'base64')) + if (key.length !== 32) { + throw new Error(`Invalid public key: expected 32 bytes, got ${key.length}`) + } + return key +} + +export function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +export function encrypt(plaintext: string, sharedKey: Uint8Array): string { + const messageBytes = new TextEncoder().encode(plaintext) + return Buffer.from(encryptBytes(messageBytes, sharedKey)).toString('base64') +} + +export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) + const plaintext = decryptBytes(bundle, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +export function encryptBytes( + plaintext: Uint8Array, + sharedKey: Uint8Array +): Uint8Array { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const ciphertext = nacl.box.after(plaintext, nonce, sharedKey) + + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + + return bundle +} + +export function decryptBytes(bundle: Uint8Array, sharedKey: Uint8Array): Uint8Array | null { + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + + const nonce = bundle.slice(0, nacl.box.nonceLength) + const ciphertext = bundle.slice(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + + if (!plaintext) { + return null + } + + return plaintext +} diff --git a/src/shared/protocol-compat.test.ts b/src/shared/protocol-compat.test.ts index 22d5d4f5fa2..79b70c2d09a 100644 --- a/src/shared/protocol-compat.test.ts +++ b/src/shared/protocol-compat.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vitest' -import { evaluateCompat } from './protocol-compat' -import { DESKTOP_PROTOCOL_VERSION, MIN_COMPATIBLE_MOBILE_VERSION } from './protocol-version' +import { + describeRuntimeCompatBlock, + evaluateCompat, + evaluateRuntimeCompat +} from './protocol-compat' +import { + DESKTOP_PROTOCOL_VERSION, + MIN_COMPATIBLE_MOBILE_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + RUNTIME_PROTOCOL_VERSION +} from './protocol-version' const MOBILE_V = 1 @@ -122,3 +132,74 @@ describe('evaluateCompat', () => { }) }) }) + +describe('evaluateRuntimeCompat', () => { + it('keeps the current client and current server self-compatible', () => { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverMinCompatibleClientProtocolVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }) + + expect(verdict).toMatchObject({ kind: 'ok' }) + }) + + it('allows client and server app versions to skew when protocol ranges overlap', () => { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION + 3, + serverMinCompatibleClientProtocolVersion: RUNTIME_PROTOCOL_VERSION - 1 + }) + + expect(verdict).toMatchObject({ kind: 'ok' }) + }) + + it('blocks when the server requires a newer client protocol', () => { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION + 1, + serverMinCompatibleClientProtocolVersion: RUNTIME_PROTOCOL_VERSION + 1 + }) + + expect(verdict).toMatchObject({ + kind: 'blocked', + reason: 'client-too-old', + requiredClientProtocolVersion: RUNTIME_PROTOCOL_VERSION + 1 + }) + expect(describeRuntimeCompatBlock(verdict)).toContain('client is too old') + }) + + it('blocks when the server protocol is below the client minimum', () => { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION - 1, + serverMinCompatibleClientProtocolVersion: 0 + }) + + expect(verdict).toMatchObject({ + kind: 'blocked', + reason: 'server-too-old', + requiredServerProtocolVersion: RUNTIME_PROTOCOL_VERSION + }) + expect(describeRuntimeCompatBlock(verdict)).toContain('server is too old') + }) + + it('treats missing server fields as protocol 0', () => { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: 1, + serverProtocolVersion: undefined, + serverMinCompatibleClientProtocolVersion: undefined + }) + + expect(verdict).toMatchObject({ + kind: 'blocked', + reason: 'server-too-old', + serverProtocolVersion: 0 + }) + }) +}) diff --git a/src/shared/protocol-compat.ts b/src/shared/protocol-compat.ts index 72477cfb482..82f9566a9a0 100644 --- a/src/shared/protocol-compat.ts +++ b/src/shared/protocol-compat.ts @@ -1,8 +1,67 @@ -// Why: pure compat evaluator shared between desktop tests and mobile -// runtime. Mobile imports a thin wrapper (`mobile/src/transport/protocol-compat.ts`) -// that injects the mobile-side constants; desktop tests import this -// directly so the function is covered by the root vitest suite. -// All four numbers are passed in to keep the function dependency-free. +// Why: pure compat evaluators shared between desktop tests, renderer runtime +// switching, and the mobile mirror. All version numbers are passed in to keep +// the logic dependency-free and easy to duplicate in Expo. + +export type RuntimeCompatVerdict = + | { + kind: 'ok' + clientProtocolVersion: number + serverProtocolVersion: number + } + | { + kind: 'blocked' + reason: 'client-too-old' | 'server-too-old' + clientProtocolVersion: number + serverProtocolVersion: number + requiredClientProtocolVersion?: number + requiredServerProtocolVersion?: number + } + +export function evaluateRuntimeCompat(input: { + clientProtocolVersion: number + minCompatibleServerProtocolVersion: number + serverProtocolVersion: number | undefined + serverMinCompatibleClientProtocolVersion: number | undefined +}): RuntimeCompatVerdict { + // Why: absent fields are protocol 0. New clients can give old servers a + // clear "update server" error instead of attempting partially-supported RPCs. + const serverProtocolVersion = input.serverProtocolVersion ?? 0 + const requiredClientProtocolVersion = input.serverMinCompatibleClientProtocolVersion ?? 0 + + if (input.clientProtocolVersion < requiredClientProtocolVersion) { + return { + kind: 'blocked', + reason: 'client-too-old', + clientProtocolVersion: input.clientProtocolVersion, + serverProtocolVersion, + requiredClientProtocolVersion + } + } + if (serverProtocolVersion < input.minCompatibleServerProtocolVersion) { + return { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: input.clientProtocolVersion, + serverProtocolVersion, + requiredServerProtocolVersion: input.minCompatibleServerProtocolVersion + } + } + return { + kind: 'ok', + clientProtocolVersion: input.clientProtocolVersion, + serverProtocolVersion + } +} + +export function describeRuntimeCompatBlock(verdict: RuntimeCompatVerdict): string { + if (verdict.kind === 'ok') { + return 'Runtime client and server are compatible.' + } + if (verdict.reason === 'client-too-old') { + return `This Orca client is too old for the selected server. Update Orca on this machine. Client protocol ${verdict.clientProtocolVersion}, server requires client protocol ${verdict.requiredClientProtocolVersion}.` + } + return `The selected Orca server is too old for this client. Update Orca on the server. Server protocol ${verdict.serverProtocolVersion}, client requires server protocol ${verdict.requiredServerProtocolVersion}.` +} export type CompatVerdict = | { kind: 'ok' } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 63aebbdc312..355c7e63666 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,23 +1,36 @@ -// Why: declares the desktop's mobile-pairing protocol version so mobile -// builds can detect declared-incompatible combos and hard-block at pair -// time. Today's values are wide-open (mobile=any, desktop=any), so -// nothing actually blocks; the wire format is ready for the day we -// ship a genuinely-breaking change. +// Why: declares the Orca runtime RPC compatibility contract. Desktop, +// headless server, CLI, and mobile builds may drift in app version, but +// they must agree on this protocol range before runtime RPCs are allowed. // -// Bump DESKTOP_PROTOCOL_VERSION when: -// - You remove an RPC method or required parameter that mobile uses. +// Bump RUNTIME_PROTOCOL_VERSION when: +// - You remove an RPC method or required parameter that clients use. // - You change the meaning (units, nullability) of an existing field -// mobile reads. -// - You change encryption, framing, or the auth handshake. +// clients read. +// - You change encrypted framing, terminal stream framing, or auth. // Do NOT bump for: // - Adding new RPC methods. // - Adding new optional fields on existing methods. -// - Adding new event types in `terminal.subscribe`. +// - Adding new ignorable event types. // -// Bump MIN_COMPATIBLE_MOBILE_VERSION when desktop ships a change that -// requires a minimum mobile version to function safely. This is the -// "kill switch": desktop can refuse old mobile builds without needing -// a desktop release of mobile. +// Bump MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION when a runtime server must +// refuse older clients. Bump MIN_COMPATIBLE_RUNTIME_SERVER_VERSION when +// this client build requires a newer server. Exact app-version equality is +// never required; these numbers define the supported compatibility window. -export const DESKTOP_PROTOCOL_VERSION = 2 -export const MIN_COMPATIBLE_MOBILE_VERSION = 2 +export const RUNTIME_PROTOCOL_VERSION = 2 +export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2 +export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2 + +export const RUNTIME_CAPABILITIES = [ + 'runtime.status.compat.v1', + 'runtime.environments.v1', + 'terminal.binary-stream.v1', + 'terminal.multiplex.v1' +] as const + +export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) + +// COMPAT(mobileProtocolAliases): added 2026-05-15 for mobile builds that +// still read desktop/mobile names; remove once mobile reads runtime names. +export const DESKTOP_PROTOCOL_VERSION = RUNTIME_PROTOCOL_VERSION +export const MIN_COMPATIBLE_MOBILE_VERSION = MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts new file mode 100644 index 00000000000..4d7e2e9526d --- /dev/null +++ b/src/shared/remote-runtime-client.test.ts @@ -0,0 +1,140 @@ +import type { AddressInfo } from 'net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebSocketServer, type WebSocket } from 'ws' +import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing' +import { + decrypt, + decryptBytes, + deriveSharedKey, + encrypt, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64 +} from './e2ee-crypto' +import { subscribeRemoteRuntimeRequest } from './remote-runtime-client' + +const servers: WebSocketServer[] = [] + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + for (const client of server.clients) { + client.close() + } + server.close(() => resolve()) + }) + ) + ) +}) + +describe('subscribeRemoteRuntimeRequest', () => { + it('sends encrypted binary frames on an established subscription socket', async () => { + const server = await createSubscriptionServer() + const onResponse = vi.fn() + const onError = vi.fn() + + const subscription = await subscribeRemoteRuntimeRequest( + server.pairing, + 'terminal.subscribe', + { terminal: 't1' }, + 1000, + { + onResponse, + onError + } + ) + + await vi.waitFor(() => + expect(onResponse).toHaveBeenCalledWith( + expect.objectContaining({ ok: true, result: { type: 'subscribed' } }) + ) + ) + const bytes = new Uint8Array([1, 2, 3]) + expect(subscription.sendBinary(bytes)).toBe(true) + await expect(server.nextBinary).resolves.toEqual(bytes) + expect(onError).not.toHaveBeenCalled() + subscription.close() + }) +}) + +async function createSubscriptionServer(): Promise<{ + pairing: PairingOffer + nextBinary: Promise +}> { + const serverKeyPair = generateKeyPair() + let resolveBinary: (bytes: Uint8Array) => void = () => {} + const nextBinary = new Promise((resolve) => { + resolveBinary = resolve + }) + const wss = new WebSocketServer({ port: 0 }) + servers.push(wss) + + wss.on('connection', (ws) => { + let sharedKey: Uint8Array | null = null + let authenticated = false + + ws.on('message', (data, isBinary) => { + if (isBinary) { + if (!sharedKey) { + return + } + const plaintext = decryptBytes(new Uint8Array(data as Buffer), sharedKey) + if (plaintext) { + resolveBinary(plaintext) + } + return + } + + const frame = data.toString() + if (!sharedKey) { + const hello = JSON.parse(frame) as { publicKeyB64: string } + sharedKey = deriveSharedKey( + serverKeyPair.secretKey, + publicKeyFromBase64(hello.publicKeyB64) + ) + ws.send(JSON.stringify({ type: 'e2ee_ready' })) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (!plaintext) { + return + } + if (!authenticated) { + authenticated = true + sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) + return + } + + const request = JSON.parse(plaintext) as { id: string } + sendEncrypted(ws, sharedKey, { + id: request.id, + ok: true, + streaming: true, + result: { type: 'subscribed' }, + _meta: { runtimeId: 'runtime-test' } + }) + }) + }) + + await new Promise((resolve) => wss.once('listening', resolve)) + const address = wss.address() as AddressInfo + const pairing = parsePairingCode( + encodePairingOffer({ + v: 2, + endpoint: `ws://127.0.0.1:${address.port}`, + deviceToken: 'device-token', + publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey) + }) + ) + if (!pairing) { + throw new Error('Failed to create test pairing') + } + return { pairing, nextBinary } +} + +function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void { + ws.send(encrypt(JSON.stringify(message), sharedKey)) +} diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts new file mode 100644 index 00000000000..23589deb894 --- /dev/null +++ b/src/shared/remote-runtime-client.ts @@ -0,0 +1,552 @@ +/* oxlint-disable max-lines -- Why: one-shot and streaming remote clients share the + * same E2EE handshake and response validation state; keep them together until + * the terminal transport is fully migrated and a stable shared connection + * abstraction emerges. */ +import { randomUUID } from 'crypto' +import WebSocket from 'ws' +import type { PairingOffer } from './pairing' +import { + decrypt, + decryptBytes, + deriveSharedKey, + encrypt, + encryptBytes, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64 +} from './e2ee-crypto' +import { RuntimeRpcEnvelopeSchema, type RuntimeRpcResponse } from './runtime-rpc-envelope' + +type HandshakeState = 'awaiting_ready' | 'awaiting_authenticated' | 'ready' + +export class RemoteRuntimeClientError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.name = 'RemoteRuntimeClientError' + this.code = code + } +} + +export type RemoteRuntimeSubscription = { + requestId: string + close: () => void + sendBinary: (bytes: Uint8Array) => boolean +} + +export type RemoteRuntimeSubscriptionCallbacks = { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError: (error: RemoteRuntimeClientError) => void + onClose?: () => void +} + +export async function sendRemoteRuntimeRequest( + pairing: PairingOffer, + method: string, + params: unknown, + timeoutMs: number +): Promise> { + return await new Promise((resolve, reject) => { + const requestId = randomUUID() + const keyPair = generateKeyPair() + const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) + const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey) + let state: HandshakeState = 'awaiting_ready' + let settled = false + let ws: WebSocket | null = null + + const timeout = setTimeout(() => { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'runtime_timeout', + 'Timed out waiting for the remote Orca runtime to respond.' + ) + }) + }, timeoutMs) + + const finish = ( + result: { ok: true; response: RuntimeRpcResponse } | { ok: false; error: Error } + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + try { + ws?.close() + } catch { + // ignore best-effort close + } + if (result.ok === false) { + reject(result.error) + } else { + resolve(result.response) + } + } + + try { + ws = new WebSocket(pairing.endpoint) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_argument', + `Invalid remote endpoint: ${message}` + ) + }) + return + } + + ws.once('open', () => { + ws?.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(keyPair.publicKey) + }) + ) + }) + + ws.once('error', () => { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Could not connect to the remote Orca runtime.' + ) + }) + }) + + ws.on('close', () => { + if (!settled) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Remote Orca runtime closed the connection.' + ) + }) + } + }) + + ws.on('message', (data, isBinary) => { + if (settled) { + return + } + if (isBinary) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an unexpected binary frame.' + ) + }) + return + } + + const frame = data.toString() + if (state === 'awaiting_ready') { + handleReadyFrame(frame) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (plaintext === null) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an undecryptable frame.' + ) + }) + return + } + + if (state === 'awaiting_authenticated') { + handleAuthenticatedFrame(plaintext) + return + } + + handleRpcFrame(plaintext) + }) + + function handleReadyFrame(frame: string): void { + let ready: unknown + try { + ready = JSON.parse(frame) + } catch { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid E2EE handshake frame.' + ) + }) + return + } + if ( + typeof ready !== 'object' || + ready === null || + (ready as { type?: unknown }).type !== 'e2ee_ready' + ) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an unexpected E2EE handshake frame.' + ) + }) + return + } + state = 'awaiting_authenticated' + ws?.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + ) + } + + function handleAuthenticatedFrame(plaintext: string): void { + let authenticated: unknown + try { + authenticated = JSON.parse(plaintext) + } catch { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid E2EE auth frame.' + ) + }) + return + } + const type = (authenticated as { type?: unknown }).type + if (type !== 'e2ee_authenticated') { + const code = + typeof authenticated === 'object' && + authenticated !== null && + (authenticated as { error?: { code?: unknown } }).error?.code === 'unauthorized' + ? 'unauthorized' + : 'invalid_runtime_response' + finish({ + ok: false, + error: new RemoteRuntimeClientError( + code, + 'Remote Orca runtime rejected the pairing token.' + ) + }) + return + } + state = 'ready' + ws?.send( + encrypt( + JSON.stringify({ + id: requestId, + deviceToken: pairing.deviceToken, + method, + params + }), + sharedKey + ) + ) + } + + function handleRpcFrame(plaintext: string): void { + let raw: unknown + try { + raw = JSON.parse(plaintext) + } catch { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid response frame.' + ) + }) + return + } + const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw) + if (!parsed.success || '_keepalive' in parsed.data) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid response frame.' + ) + }) + return + } + const response = parsed.data as RuntimeRpcResponse + if (response.id !== requestId) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned a mismatched response id.' + ) + }) + return + } + finish({ ok: true, response }) + } + }) +} + +export async function subscribeRemoteRuntimeRequest( + pairing: PairingOffer, + method: string, + params: unknown, + timeoutMs: number, + callbacks: RemoteRuntimeSubscriptionCallbacks +): Promise { + return await new Promise((resolve, reject) => { + const requestId = randomUUID() + const keyPair = generateKeyPair() + const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) + const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey) + let state: HandshakeState = 'awaiting_ready' + let settled = false + let ws: WebSocket | null = null + + const timeout = setTimeout(() => { + fail( + new RemoteRuntimeClientError( + 'runtime_timeout', + 'Timed out waiting for the remote Orca runtime subscription to start.' + ) + ) + }, timeoutMs) + + const close = (): void => { + try { + ws?.close() + } catch { + // ignore best-effort close + } + } + + const sendBinary = (bytes: Uint8Array): boolean => { + if (state !== 'ready' || !ws || ws.readyState !== WebSocket.OPEN) { + return false + } + ws.send(Buffer.from(encryptBytes(bytes, sharedKey)), { binary: true }) + return true + } + + const succeed = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + resolve({ requestId, close, sendBinary }) + } + + const fail = (error: RemoteRuntimeClientError): void => { + if (!settled) { + settled = true + clearTimeout(timeout) + close() + reject(error) + return + } + callbacks.onError(error) + } + + try { + ws = new WebSocket(pairing.endpoint) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + fail(new RemoteRuntimeClientError('invalid_argument', `Invalid remote endpoint: ${message}`)) + return + } + + ws.once('open', () => { + ws?.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(keyPair.publicKey) + }) + ) + }) + + ws.once('error', () => { + fail( + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Could not connect to the remote Orca runtime.' + ) + ) + }) + + ws.on('close', () => { + clearTimeout(timeout) + if (!settled) { + reject( + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Remote Orca runtime closed the connection.' + ) + ) + return + } + callbacks.onClose?.() + }) + + ws.on('message', (data, isBinary) => { + if (isBinary) { + handleBinaryFrame(new Uint8Array(data as Buffer)) + return + } + + const frame = data.toString() + if (state === 'awaiting_ready') { + handleReadyFrame(frame) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (plaintext === null) { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an undecryptable frame.' + ) + ) + return + } + + if (state === 'awaiting_authenticated') { + handleAuthenticatedFrame(plaintext) + return + } + + handleRpcFrame(plaintext) + }) + + function handleReadyFrame(frame: string): void { + let ready: unknown + try { + ready = JSON.parse(frame) + } catch { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid E2EE handshake frame.' + ) + ) + return + } + if ( + typeof ready !== 'object' || + ready === null || + (ready as { type?: unknown }).type !== 'e2ee_ready' + ) { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an unexpected E2EE handshake frame.' + ) + ) + return + } + state = 'awaiting_authenticated' + ws?.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + ) + } + + function handleAuthenticatedFrame(plaintext: string): void { + let authenticated: unknown + try { + authenticated = JSON.parse(plaintext) + } catch { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid E2EE auth frame.' + ) + ) + return + } + const type = (authenticated as { type?: unknown }).type + if (type !== 'e2ee_authenticated') { + const code = + typeof authenticated === 'object' && + authenticated !== null && + (authenticated as { error?: { code?: unknown } }).error?.code === 'unauthorized' + ? 'unauthorized' + : 'invalid_runtime_response' + fail(new RemoteRuntimeClientError(code, 'Remote Orca runtime rejected the pairing token.')) + return + } + state = 'ready' + ws?.send( + encrypt( + JSON.stringify({ + id: requestId, + deviceToken: pairing.deviceToken, + method, + params + }), + sharedKey + ) + ) + succeed() + } + + function handleRpcFrame(plaintext: string): void { + let raw: unknown + try { + raw = JSON.parse(plaintext) + } catch { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid response frame.' + ) + ) + return + } + const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw) + if (!parsed.success || '_keepalive' in parsed.data) { + return + } + const response = parsed.data as RuntimeRpcResponse + if (response.id !== requestId) { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned a mismatched response id.' + ) + ) + return + } + callbacks.onResponse(response) + } + + function handleBinaryFrame(frame: Uint8Array): void { + if (state !== 'ready') { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned binary data before authentication.' + ) + ) + return + } + const plaintext = decryptBytes(frame, sharedKey) + if (plaintext === null) { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an undecryptable binary frame.' + ) + ) + return + } + callbacks.onBinary?.(plaintext) + } + }) +} diff --git a/src/shared/remote-runtime-request-connection-stale.test.ts b/src/shared/remote-runtime-request-connection-stale.test.ts new file mode 100644 index 00000000000..39afac27b6e --- /dev/null +++ b/src/shared/remote-runtime-request-connection-stale.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import type { PairingOffer } from './pairing' +import { decrypt, encrypt } from './e2ee-crypto' +import type { RemoteRuntimeWebSocketCallbacks } from './remote-runtime-request-websocket' + +const opens: FakeOpenedSocket[] = [] + +vi.mock('./remote-runtime-request-websocket', () => ({ + openRemoteRuntimeWebSocket: ( + _pairing: PairingOffer, + callbacks: RemoteRuntimeWebSocketCallbacks + ) => { + const socket = createFakeOpenedSocket(callbacks) + opens.push(socket) + return { ok: true, socket: { ws: socket.ws, sharedKey: socket.sharedKey } } + } +})) + +type FakeOpenedSocket = { + ws: WebSocket + sharedKey: Uint8Array + sent: string[] + callbacks: RemoteRuntimeWebSocketCallbacks +} + +function createFakeOpenedSocket(callbacks: RemoteRuntimeWebSocketCallbacks): FakeOpenedSocket { + const sent: string[] = [] + const ws = { + readyState: WebSocket.OPEN, + send: (frame: string) => { + sent.push(frame) + }, + close: vi.fn() + } as unknown as WebSocket + return { + ws, + sharedKey: new Uint8Array(32).fill(opens.length + 1), + sent, + callbacks + } +} + +function authenticate(socket: FakeOpenedSocket): void { + socket.callbacks.onTextFrame(socket.ws, JSON.stringify({ type: 'e2ee_ready' })) + socket.callbacks.onTextFrame( + socket.ws, + encrypt(JSON.stringify({ type: 'e2ee_authenticated' }), socket.sharedKey) + ) +} + +function latestRequestId(socket: FakeOpenedSocket): string { + const plaintext = decrypt(socket.sent.at(-1) ?? '', socket.sharedKey) + if (plaintext === null) { + throw new Error('missing encrypted request') + } + return (JSON.parse(plaintext) as { id: string }).id +} + +describe('RemoteRuntimeRequestConnection stale socket callbacks', () => { + beforeEach(() => { + opens.splice(0) + }) + + it('ignores stale socket errors and text frames after a replacement socket opens', async () => { + vi.useFakeTimers() + try { + const { RemoteRuntimeRequestConnection } = + await import('./remote-runtime-request-connection.js') + const connection = new RemoteRuntimeRequestConnection({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(9)).toString('base64') + }) + + const first = connection.request('slow.method', undefined, 10) + authenticate(opens[0]!) + const firstRejected = expect(first).rejects.toThrow('Timed out') + await vi.advanceTimersByTimeAsync(11) + await firstRejected + + const second = connection.request('status.get', undefined, 1000) + authenticate(opens[1]!) + await vi.waitFor(() => expect(opens[1]!.sent.length).toBeGreaterThan(1)) + + opens[0]!.callbacks.onError(opens[0]!.ws, new Error('stale socket error') as never) + opens[0]!.callbacks.onTextFrame( + opens[0]!.ws, + encrypt(JSON.stringify({ id: 'stale', ok: true, result: {} }), opens[0]!.sharedKey) + ) + + const requestId = latestRequestId(opens[1]!) + opens[1]!.callbacks.onTextFrame( + opens[1]!.ws, + encrypt( + JSON.stringify({ + id: requestId, + ok: true, + result: { state: 'ok' }, + _meta: { runtimeId: 'runtime-2' } + }), + opens[1]!.sharedKey + ) + ) + + await expect(second).resolves.toMatchObject({ + ok: true, + result: { state: 'ok' } + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/shared/remote-runtime-request-connection.test.ts b/src/shared/remote-runtime-request-connection.test.ts new file mode 100644 index 00000000000..3a9d7f1b191 --- /dev/null +++ b/src/shared/remote-runtime-request-connection.test.ts @@ -0,0 +1,142 @@ +import type { AddressInfo } from 'net' +import { afterEach, describe, expect, it } from 'vitest' +import { WebSocketServer, type WebSocket } from 'ws' +import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing' +import { + decrypt, + deriveSharedKey, + encrypt, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64 +} from './e2ee-crypto' +import { RemoteRuntimeRequestConnection } from './remote-runtime-request-connection' + +type TestServer = { + wss: WebSocketServer + pairing: PairingOffer + requests: unknown[] + connectionCount: () => number +} + +const servers: WebSocketServer[] = [] + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + for (const client of server.clients) { + client.close() + } + server.close(() => resolve()) + }) + ) + ) +}) + +describe('RemoteRuntimeRequestConnection', () => { + it('reuses one encrypted WebSocket for multiple one-shot RPCs', async () => { + const server = await createServer() + const connection = new RemoteRuntimeRequestConnection(server.pairing) + + const first = await connection.request('status.get', undefined, 1000) + const second = await connection.request('terminal.send', { terminal: 't1', text: 'ab' }, 1000) + + expect(first).toMatchObject({ + ok: true, + result: { method: 'status.get' }, + _meta: { runtimeId: 'runtime-test' } + }) + expect(second).toMatchObject({ + ok: true, + result: { method: 'terminal.send' }, + _meta: { runtimeId: 'runtime-test' } + }) + expect(server.connectionCount()).toBe(1) + expect(server.requests).toMatchObject([ + { method: 'status.get' }, + { method: 'terminal.send', params: { terminal: 't1', text: 'ab' } } + ]) + + connection.close() + }) +}) + +async function createServer(): Promise { + const serverKeyPair = generateKeyPair() + const requests: unknown[] = [] + let connectionCount = 0 + const wss = new WebSocketServer({ port: 0 }) + servers.push(wss) + + wss.on('connection', (ws) => { + connectionCount += 1 + let sharedKey: Uint8Array | null = null + let authenticated = false + + ws.on('message', (data, isBinary) => { + if (isBinary) { + return + } + const frame = data.toString() + if (!sharedKey) { + const hello = JSON.parse(frame) as { type: string; publicKeyB64: string } + const clientPublicKey = publicKeyFromBase64(hello.publicKeyB64) + sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) + ws.send(JSON.stringify({ type: 'e2ee_ready' })) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (plaintext === null) { + return + } + if (!authenticated) { + const auth = JSON.parse(plaintext) as { type: string; deviceToken: string } + expect(auth).toEqual({ type: 'e2ee_auth', deviceToken: 'device-token' }) + authenticated = true + sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) + return + } + + const request = JSON.parse(plaintext) as { + id: string + method: string + params?: unknown + } + requests.push(request) + sendEncrypted(ws, sharedKey, { + id: request.id, + ok: true, + result: { method: request.method }, + _meta: { runtimeId: 'runtime-test' } + }) + }) + }) + + await new Promise((resolve) => wss.once('listening', resolve)) + const address = wss.address() as AddressInfo + const pairing = parsePairingCode( + encodePairingOffer({ + v: 2, + endpoint: `ws://127.0.0.1:${address.port}`, + deviceToken: 'device-token', + publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey) + }) + ) + if (!pairing) { + throw new Error('Failed to create test pairing') + } + + return { + wss, + pairing, + requests, + connectionCount: () => connectionCount + } +} + +function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void { + ws.send(encrypt(JSON.stringify(message), sharedKey)) +} diff --git a/src/shared/remote-runtime-request-connection.ts b/src/shared/remote-runtime-request-connection.ts new file mode 100644 index 00000000000..17f6605ab87 --- /dev/null +++ b/src/shared/remote-runtime-request-connection.ts @@ -0,0 +1,292 @@ +import { randomUUID } from 'crypto' +import WebSocket from 'ws' +import type { PairingOffer } from './pairing' +import { decrypt, encrypt } from './e2ee-crypto' +import type { RuntimeRpcResponse } from './runtime-rpc-envelope' +import { RemoteRuntimeClientError } from './remote-runtime-client' +import { + invalidRemoteRuntimeResponseError, + parseAuthenticatedFrame, + parseReadyFrame, + parseRemoteRuntimeRpcFrame, + remoteRuntimeTimeoutError, + remoteRuntimeUnavailableError +} from './remote-runtime-request-frames' +import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket' + +type ConnectionState = 'closed' | 'awaiting_ready' | 'awaiting_authenticated' | 'ready' + +type PendingRequest = { + resolve: (response: RuntimeRpcResponse) => void + reject: (error: Error) => void + timeout: ReturnType +} + +type ReadyWaiter = { + resolve: () => void + reject: (error: Error) => void +} + +const IDLE_CLOSE_MS = 60_000 + +export class RemoteRuntimeRequestConnection { + private readonly pairing: PairingOffer + private state: ConnectionState = 'closed' + private ws: WebSocket | null = null + private sharedKey: Uint8Array | null = null + private readonly pendingRequests = new Map>() + private readonly readyWaiters: ReadyWaiter[] = [] + private idleCloseTimer: ReturnType | null = null + + constructor(pairing: PairingOffer) { + this.pairing = pairing + } + + request( + method: string, + params: unknown, + timeoutMs: number + ): Promise> { + this.clearIdleCloseTimer() + const requestId = randomUUID() + return new Promise>((resolve, reject) => { + const timeout = setTimeout(() => { + const pending = this.pendingRequests.get(requestId) + if (!pending) { + return + } + this.pendingRequests.delete(requestId) + const error = remoteRuntimeTimeoutError() + pending.reject(error) + this.close(error) + }, timeoutMs) + this.pendingRequests.set(requestId, { + resolve: resolve as (response: RuntimeRpcResponse) => void, + reject, + timeout + }) + + void this.ensureReady().then( + () => this.sendRequest(requestId, method, params), + (error) => this.rejectPendingRequest(requestId, toClientError(error)) + ) + }) + } + + close(error?: Error): void { + const ws = this.ws + this.ws = null + this.sharedKey = null + this.state = 'closed' + this.clearIdleCloseTimer() + + const closeError = error ?? remoteRuntimeUnavailableError() + this.rejectReadyWaiters(closeError) + for (const [requestId, pending] of this.pendingRequests) { + clearTimeout(pending.timeout) + this.pendingRequests.delete(requestId) + pending.reject(closeError) + } + + try { + ws?.close() + } catch { + // Best-effort shutdown for a cached remote control connection. + } + } + + private ensureReady(): Promise { + const ws = this.ws + if (this.state === 'ready' && ws?.readyState === WebSocket.OPEN && this.sharedKey) { + return Promise.resolve() + } + + const promise = new Promise((resolve, reject) => { + this.readyWaiters.push({ resolve, reject }) + }) + + if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { + this.open() + } + + return promise + } + + private open(): void { + const opened = openRemoteRuntimeWebSocket(this.pairing, { + onClose: (ws) => { + if (this.ws === ws) { + this.close() + } + }, + onError: (ws, error) => { + if (this.ws === ws) { + this.close(error) + } + }, + onTextFrame: (ws, frame) => { + if (this.ws === ws) { + this.handleTextFrame(frame) + } + } + }) + if (!opened.ok) { + this.close(opened.error) + return + } + this.ws = opened.socket.ws + this.sharedKey = opened.socket.sharedKey + this.state = 'awaiting_ready' + } + + private handleTextFrame(frame: string): void { + if (this.state === 'awaiting_ready') { + this.handleReadyFrame(frame) + return + } + + const sharedKey = this.sharedKey + if (!sharedKey) { + return + } + const plaintext = decrypt(frame, sharedKey) + if (plaintext === null) { + this.close( + invalidRemoteRuntimeResponseError('Remote Orca runtime returned an undecryptable frame.') + ) + return + } + + if (this.state === 'awaiting_authenticated') { + this.handleAuthenticatedFrame(plaintext) + return + } + + this.handleRpcFrame(plaintext) + } + + private handleReadyFrame(frame: string): void { + const error = parseReadyFrame(frame) + if (error) { + this.close(error) + return + } + this.state = 'awaiting_authenticated' + const sharedKey = this.sharedKey + if (!sharedKey) { + return + } + this.ws?.send( + encrypt( + JSON.stringify({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken }), + sharedKey + ) + ) + } + + private handleAuthenticatedFrame(plaintext: string): void { + const error = parseAuthenticatedFrame(plaintext) + if (error) { + this.close(error) + return + } + this.state = 'ready' + this.resolveReadyWaiters() + this.scheduleIdleCloseIfUnused() + } + + private handleRpcFrame(plaintext: string): void { + const parsed = parseRemoteRuntimeRpcFrame(plaintext) + if (parsed.type === 'keepalive') { + return + } + if (parsed.type === 'error') { + this.close(parsed.error) + return + } + + const response = parsed.response + const pending = this.pendingRequests.get(response.id) + if (!pending) { + return + } + this.pendingRequests.delete(response.id) + clearTimeout(pending.timeout) + pending.resolve(response) + this.scheduleIdleCloseIfUnused() + } + + private sendRequest(requestId: string, method: string, params: unknown): void { + const pending = this.pendingRequests.get(requestId) + const ws = this.ws + const sharedKey = this.sharedKey + if (!pending) { + return + } + if (this.state !== 'ready' || !ws || ws.readyState !== WebSocket.OPEN || !sharedKey) { + this.rejectPendingRequest(requestId, remoteRuntimeUnavailableError()) + return + } + ws.send( + encrypt( + JSON.stringify({ + id: requestId, + deviceToken: this.pairing.deviceToken, + method, + params + }), + sharedKey + ) + ) + } + + private rejectPendingRequest(requestId: string, error: Error): void { + const pending = this.pendingRequests.get(requestId) + if (!pending) { + return + } + this.pendingRequests.delete(requestId) + clearTimeout(pending.timeout) + pending.reject(error) + this.scheduleIdleCloseIfUnused() + } + + private resolveReadyWaiters(): void { + const waiters = this.readyWaiters.splice(0) + for (const waiter of waiters) { + waiter.resolve() + } + } + + private rejectReadyWaiters(error: Error): void { + const waiters = this.readyWaiters.splice(0) + for (const waiter of waiters) { + waiter.reject(error) + } + } + + private scheduleIdleCloseIfUnused(): void { + if (this.pendingRequests.size > 0 || this.readyWaiters.length > 0 || this.state !== 'ready') { + return + } + this.clearIdleCloseTimer() + this.idleCloseTimer = setTimeout(() => this.close(), IDLE_CLOSE_MS) + if (typeof this.idleCloseTimer.unref === 'function') { + this.idleCloseTimer.unref() + } + } + + private clearIdleCloseTimer(): void { + if (this.idleCloseTimer) { + clearTimeout(this.idleCloseTimer) + this.idleCloseTimer = null + } + } +} + +function toClientError(error: unknown): Error { + if (error instanceof Error) { + return error + } + return new RemoteRuntimeClientError('runtime_error', String(error)) +} diff --git a/src/shared/remote-runtime-request-frames.ts b/src/shared/remote-runtime-request-frames.ts new file mode 100644 index 00000000000..b48212d0f5b --- /dev/null +++ b/src/shared/remote-runtime-request-frames.ts @@ -0,0 +1,98 @@ +import { + RuntimeRpcEnvelopeSchema, + type RuntimeRpcResponse, + isKeepaliveFrame +} from './runtime-rpc-envelope' +import { RemoteRuntimeClientError } from './remote-runtime-client' + +export type ParsedRemoteRuntimeFrame = + | { type: 'keepalive' } + | { type: 'response'; response: RuntimeRpcResponse } + | { type: 'error'; error: RemoteRuntimeClientError } + +export function remoteRuntimeUnavailableError( + message = 'Remote Orca runtime closed the connection.' +): RemoteRuntimeClientError { + return new RemoteRuntimeClientError('remote_runtime_unavailable', message) +} + +export function remoteRuntimeTimeoutError(): RemoteRuntimeClientError { + return new RemoteRuntimeClientError( + 'runtime_timeout', + 'Timed out waiting for the remote Orca runtime to respond.' + ) +} + +export function invalidRemoteRuntimeResponseError(message: string): RemoteRuntimeClientError { + return new RemoteRuntimeClientError('invalid_runtime_response', message) +} + +export function parseReadyFrame(frame: string): RemoteRuntimeClientError | null { + let ready: unknown + try { + ready = JSON.parse(frame) + } catch { + return invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an invalid E2EE handshake frame.' + ) + } + if ( + typeof ready !== 'object' || + ready === null || + (ready as { type?: unknown }).type !== 'e2ee_ready' + ) { + return invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an unexpected E2EE handshake frame.' + ) + } + return null +} + +export function parseAuthenticatedFrame(plaintext: string): RemoteRuntimeClientError | null { + let authenticated: unknown + try { + authenticated = JSON.parse(plaintext) + } catch { + return invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an invalid E2EE auth frame.' + ) + } + const type = (authenticated as { type?: unknown }).type + if (type === 'e2ee_authenticated') { + return null + } + const code = + typeof authenticated === 'object' && + authenticated !== null && + (authenticated as { error?: { code?: unknown } }).error?.code === 'unauthorized' + ? 'unauthorized' + : 'invalid_runtime_response' + return new RemoteRuntimeClientError(code, 'Remote Orca runtime rejected the pairing token.') +} + +export function parseRemoteRuntimeRpcFrame(plaintext: string): ParsedRemoteRuntimeFrame { + let raw: unknown + try { + raw = JSON.parse(plaintext) + } catch { + return { + type: 'error', + error: invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an invalid response frame.' + ) + } + } + if (isKeepaliveFrame(raw)) { + return { type: 'keepalive' } + } + const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw) + if (!parsed.success || '_keepalive' in parsed.data) { + return { + type: 'error', + error: invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an invalid response frame.' + ) + } + } + return { type: 'response', response: parsed.data as RuntimeRpcResponse } +} diff --git a/src/shared/remote-runtime-request-websocket.ts b/src/shared/remote-runtime-request-websocket.ts new file mode 100644 index 00000000000..569ef9e2759 --- /dev/null +++ b/src/shared/remote-runtime-request-websocket.ts @@ -0,0 +1,96 @@ +import WebSocket from 'ws' +import type { PairingOffer } from './pairing' +import { + deriveSharedKey, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64 +} from './e2ee-crypto' +import { RemoteRuntimeClientError } from './remote-runtime-client' +import { + invalidRemoteRuntimeResponseError, + remoteRuntimeUnavailableError +} from './remote-runtime-request-frames' + +export type RemoteRuntimeWebSocket = { + ws: WebSocket + sharedKey: Uint8Array +} + +export type RemoteRuntimeWebSocketCallbacks = { + onClose: (ws: WebSocket) => void + onError: (ws: WebSocket, error: RemoteRuntimeClientError) => void + onTextFrame: (ws: WebSocket, frame: string) => void +} + +export function openRemoteRuntimeWebSocket( + pairing: PairingOffer, + callbacks: RemoteRuntimeWebSocketCallbacks +): { ok: true; socket: RemoteRuntimeWebSocket } | { ok: false; error: RemoteRuntimeClientError } { + const opened = createSocket(pairing) + if (!opened.ok) { + return opened + } + const { ws, keyPair } = opened + const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) + const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey) + + ws.once('open', () => { + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(keyPair.publicKey) + }) + ) + }) + ws.on('error', () => { + callbacks.onError( + ws, + remoteRuntimeUnavailableError('Could not connect to the remote Orca runtime.') + ) + }) + ws.on('close', () => callbacks.onClose(ws)) + ws.on('message', (data, isBinary) => { + if (isBinary) { + callbacks.onError( + ws, + invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an unexpected binary frame.' + ) + ) + return + } + callbacks.onTextFrame(ws, data.toString()) + }) + return { ok: true, socket: { ws, sharedKey } } +} + +function createSocket( + pairing: PairingOffer +): + | { ok: true; ws: WebSocket; keyPair: ReturnType } + | { ok: false; error: RemoteRuntimeClientError } { + let keyPair: ReturnType + try { + keyPair = generateKeyPair() + publicKeyFromBase64(pairing.publicKeyB64) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_argument', + `Invalid remote pairing key: ${message}` + ) + } + } + try { + return { ok: true, ws: new WebSocket(pairing.endpoint), keyPair } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + ok: false, + error: new RemoteRuntimeClientError('invalid_argument', `Invalid remote endpoint: ${message}`) + } + } +} diff --git a/src/shared/runtime-environment-store.test.ts b/src/shared/runtime-environment-store.test.ts new file mode 100644 index 00000000000..649775d11b7 --- /dev/null +++ b/src/shared/runtime-environment-store.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { encodePairingOffer } from './pairing' +import { + RuntimeEnvironmentStoreError, + addEnvironmentFromPairingCode, + listEnvironments +} from './runtime-environment-store' + +function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { + return encodePairingOffer({ + v: 2, + endpoint, + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) +} + +describe('runtime environment store', () => { + const tempDirs: string[] = [] + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects duplicate server names instead of silently replacing the saved server', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) + tempDirs.push(userDataPath) + + const first = addEnvironmentFromPairingCode(userDataPath, { + name: 'dev box', + pairingCode: pairingCode('ws://127.0.0.1:6768') + }) + + expect(() => + addEnvironmentFromPairingCode(userDataPath, { + name: 'dev box', + pairingCode: pairingCode('ws://192.0.2.10:6768') + }) + ).toThrow(RuntimeEnvironmentStoreError) + expect(listEnvironments(userDataPath)).toEqual([first]) + }) +}) diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts new file mode 100644 index 00000000000..2682ffb74dd --- /dev/null +++ b/src/shared/runtime-environment-store.ts @@ -0,0 +1,166 @@ +import { randomUUID } from 'crypto' +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { parsePairingCode, type PairingOffer } from './pairing' +import { hardenExistingSecureFile, writeSecureJsonFile } from './secure-file' +import { + createEnvironmentFromPairingOffer, + getPreferredPairingOffer, + KnownRuntimeEnvironmentSchema, + RuntimeEnvironmentStoreSchema, + type KnownRuntimeEnvironment, + type RuntimeEnvironmentStore +} from './runtime-environments' + +const ENVIRONMENTS_FILE = 'orca-environments.json' + +export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error' + +export class RuntimeEnvironmentStoreError extends Error { + readonly code: RuntimeEnvironmentStoreErrorCode + + constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) { + super(message) + this.name = 'RuntimeEnvironmentStoreError' + this.code = code + } +} + +export function getEnvironmentStorePath(userDataPath: string): string { + return join(userDataPath, ENVIRONMENTS_FILE) +} + +export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[] { + return readEnvironmentStore(userDataPath).environments +} + +export function addEnvironmentFromPairingCode( + userDataPath: string, + args: { name: string; pairingCode: string; now?: number } +): KnownRuntimeEnvironment { + const offer = parsePairingCode(args.pairingCode) + if (!offer) { + throw new RuntimeEnvironmentStoreError( + 'invalid_argument', + 'Invalid pairing code. Expected an orca://pair#... URL or bare pairing payload.' + ) + } + const store = readEnvironmentStore(userDataPath) + const now = args.now ?? Date.now() + const existing = store.environments.find((entry) => entry.name === args.name) + if (existing) { + throw new RuntimeEnvironmentStoreError( + 'invalid_argument', + `A server named "${args.name}" already exists.` + ) + } + const environment = createEnvironmentFromPairingOffer({ + id: randomUUID(), + name: args.name, + now, + offer, + runtimeId: null + }) + const next = { + version: 1 as const, + environments: [ + ...store.environments.filter((entry) => entry.id !== environment.id), + environment + ].sort((a, b) => a.name.localeCompare(b.name)) + } + writeEnvironmentStore(userDataPath, next) + return environment +} + +export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment { + const store = readEnvironmentStore(userDataPath) + const environment = resolveEnvironmentFromStore(store, selector) + writeEnvironmentStore(userDataPath, { + version: 1, + environments: store.environments.filter((entry) => entry.id !== environment.id) + }) + return environment +} + +export function resolveEnvironment( + userDataPath: string, + selector: string +): KnownRuntimeEnvironment { + return resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector) +} + +export function resolveEnvironmentPairingOffer( + userDataPath: string, + selector: string +): PairingOffer { + return getPreferredPairingOffer(resolveEnvironment(userDataPath, selector)) +} + +export function markEnvironmentUsed( + userDataPath: string, + selector: string, + args: { runtimeId?: string | null; now?: number } = {} +): void { + const store = readEnvironmentStore(userDataPath) + const environment = resolveEnvironmentFromStore(store, selector) + const now = args.now ?? Date.now() + const next = store.environments.map((entry) => + entry.id === environment.id + ? { + ...entry, + runtimeId: args.runtimeId ?? entry.runtimeId, + lastUsedAt: now, + updatedAt: now + } + : entry + ) + writeEnvironmentStore(userDataPath, { version: 1, environments: next }) +} + +function resolveEnvironmentFromStore( + store: RuntimeEnvironmentStore, + selector: string +): KnownRuntimeEnvironment { + const byId = store.environments.find((entry) => entry.id === selector) + if (byId) { + return byId + } + const matches = store.environments.filter((entry) => entry.name === selector) + if (matches.length === 1) { + return matches[0]! + } + if (matches.length > 1) { + throw new RuntimeEnvironmentStoreError( + 'invalid_argument', + `Environment name "${selector}" is ambiguous; use the environment id.` + ) + } + throw new RuntimeEnvironmentStoreError('invalid_argument', `Unknown environment: ${selector}`) +} + +function readEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore { + const path = getEnvironmentStorePath(userDataPath) + if (!existsSync(path)) { + return { version: 1, environments: [] } + } + try { + hardenExistingSecureFile(path) + const parsed = RuntimeEnvironmentStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) + return { + version: 1, + environments: parsed.environments + .map((entry) => KnownRuntimeEnvironmentSchema.parse(entry)) + .sort((a, b) => a.name.localeCompare(b.name)) + } + } catch { + throw new RuntimeEnvironmentStoreError( + 'runtime_error', + `Could not read Orca environments at ${path}; the file is invalid.` + ) + } +} + +function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void { + const path = getEnvironmentStorePath(userDataPath) + writeSecureJsonFile(path, RuntimeEnvironmentStoreSchema.parse(store)) +} diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts new file mode 100644 index 00000000000..4f315b42dba --- /dev/null +++ b/src/shared/runtime-environments.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' +import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing' + +export const RuntimeAccessEndpointSchema = z.object({ + id: z.string().min(1), + kind: z.literal('websocket'), + label: z.string().min(1), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + publicKeyB64: z.string().min(1) +}) + +export type RuntimeAccessEndpoint = z.infer + +export const PublicRuntimeAccessEndpointSchema = RuntimeAccessEndpointSchema.omit({ + deviceToken: true, + publicKeyB64: true +}) + +export type PublicRuntimeAccessEndpoint = z.infer + +export const KnownRuntimeEnvironmentSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + createdAt: z.number().finite(), + updatedAt: z.number().finite(), + lastUsedAt: z.number().finite().nullable(), + runtimeId: z.string().min(1).nullable(), + endpoints: z.array(RuntimeAccessEndpointSchema).min(1), + preferredEndpointId: z.string().min(1) +}) + +export type KnownRuntimeEnvironment = z.infer + +export type PublicKnownRuntimeEnvironment = Omit & { + endpoints: PublicRuntimeAccessEndpoint[] +} + +export function redactRuntimeEnvironment( + environment: KnownRuntimeEnvironment +): PublicKnownRuntimeEnvironment { + return { + ...environment, + endpoints: environment.endpoints.map( + ({ deviceToken: _deviceToken, publicKeyB64: _key, ...rest }) => rest + ) + } +} + +export const RuntimeEnvironmentStoreSchema = z.object({ + version: z.literal(1), + environments: z.array(KnownRuntimeEnvironmentSchema) +}) + +export type RuntimeEnvironmentStore = z.infer + +export function createEnvironmentFromPairingOffer(args: { + id: string + name: string + now: number + offer: PairingOffer + runtimeId?: string | null +}): KnownRuntimeEnvironment { + const endpointId = `ws-${args.id}` + return KnownRuntimeEnvironmentSchema.parse({ + id: args.id, + name: args.name, + createdAt: args.now, + updatedAt: args.now, + lastUsedAt: null, + runtimeId: args.runtimeId ?? null, + endpoints: [ + { + id: endpointId, + kind: 'websocket', + label: 'WebSocket', + endpoint: args.offer.endpoint, + deviceToken: args.offer.deviceToken, + publicKeyB64: args.offer.publicKeyB64 + } + ], + preferredEndpointId: endpointId + }) +} + +export function getPreferredPairingOffer(environment: KnownRuntimeEnvironment): PairingOffer { + const endpoint = + environment.endpoints.find((entry) => entry.id === environment.preferredEndpointId) ?? + environment.endpoints[0] + if (!endpoint) { + throw new Error(`Environment ${environment.name} has no access endpoints`) + } + return { + v: PAIRING_OFFER_VERSION, + endpoint: endpoint.endpoint, + deviceToken: endpoint.deviceToken, + publicKeyB64: endpoint.publicKeyB64 + } +} diff --git a/src/shared/runtime-rpc-envelope.ts b/src/shared/runtime-rpc-envelope.ts new file mode 100644 index 00000000000..c72811c18a3 --- /dev/null +++ b/src/shared/runtime-rpc-envelope.ts @@ -0,0 +1,73 @@ +// Why: runtime clients can be CLI, desktop, or future non-Electron shells. +// Keeping the envelope contract here avoids making those clients import each +// other just to validate the shared RPC frame shape. +import { z } from 'zod' + +const MetaSuccess = z.object({ + runtimeId: z.string() +}) + +const MetaFailure = z + .object({ + runtimeId: z.union([z.string(), z.null()]) + }) + .optional() + +const Success = z.object({ + id: z.string(), + ok: z.literal(true), + result: z.unknown(), + _meta: MetaSuccess +}) + +const Failure = z.object({ + id: z.string(), + ok: z.literal(false), + error: z.object({ + code: z.string(), + message: z.string(), + data: z.unknown().optional() + }), + _meta: MetaFailure +}) + +const Keepalive = z.object({ + _keepalive: z.literal(true) +}) + +export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive]) + +export type RuntimeRpcSuccess = { + id: string + ok: true + result: TResult + _meta: { + runtimeId: string + } +} + +export type RuntimeRpcFailure = { + id: string + ok: false + error: { + code: string + message: string + data?: unknown + } + _meta?: { + runtimeId: string | null + } +} + +export type RuntimeRpcResponse = RuntimeRpcSuccess | RuntimeRpcFailure + +export type RuntimeRpcKeepaliveFrame = z.infer + +export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame { + return ( + typeof frame === 'object' && + frame !== null && + '_keepalive' in frame && + (frame as { _keepalive: unknown })._keepalive === true + ) +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 97070dfe649..586252341e3 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -1,10 +1,18 @@ /* eslint-disable max-lines -- Why: shared type definitions for all runtime RPC methods live in one file for discoverability and import simplicity. */ import type { TerminalPaneLayoutNode } from './types' -import type { BrowserSessionProfile, GitWorktreeInfo, Repo } from './types' +import type { + BrowserCookieImportResult, + BrowserSessionProfile, + BrowserSessionProfileSource, + GitWorktreeInfo, + Repo, + Worktree +} from './types' import type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } from './mobile-markdown-document' +import type { RuntimeCapability } from './protocol-version' export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } @@ -17,9 +25,13 @@ export type RuntimeStatus = { authoritativeWindowId: number | null liveTabCount: number liveLeafCount: number - // Why: optional so mobile builds can read both new and pre-PR desktops. - // Absence is treated as 0 by mobile's compat evaluator. See - // src/shared/protocol-version.ts for bump discipline. + // Why: optional so clients can read both new and pre-contract runtimes. + // Absence is treated as protocol 0 by the compat evaluator. + runtimeProtocolVersion?: number + minCompatibleRuntimeClientVersion?: number + capabilities?: RuntimeCapability[] + // COMPAT(runtimeStatusMobileAliases): added 2026-05-15 for mobile builds + // that still read these names; new desktop/CLI code uses the fields above. protocolVersion?: number minCompatibleMobileVersion?: number } @@ -189,6 +201,13 @@ export type RuntimeFileReadResult = { byteLength: number } +export type RuntimeFilePreviewResult = { + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string +} + export type RuntimeTerminalSummary = { handle: string worktreeId: string @@ -292,15 +311,8 @@ export type RuntimeWorktreePsSummary = { export type RuntimeWorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive' -export type RuntimeWorktreeRecord = { - id: string - repoId: string - path: string - branch: string - linkedIssue: number | null +export type RuntimeWorktreeRecord = Worktree & { git: GitWorktreeInfo - displayName: string - comment: string } export type RuntimeWorktreePsResult = { @@ -446,6 +458,28 @@ export type BrowserProfileDeleteResult = { profileId: string } +export type BrowserDetectedProfileInfo = { + name: string + directory: string +} + +export type BrowserDetectedInfo = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + profiles: BrowserDetectedProfileInfo[] + selectedProfile: string +} + +export type BrowserDetectProfilesResult = { + browsers: BrowserDetectedInfo[] +} + +export type BrowserProfileImportFromBrowserResult = BrowserCookieImportResult + +export type BrowserProfileClearDefaultCookiesResult = { + cleared: boolean +} + export type BrowserHoverResult = { hovered: string } diff --git a/src/shared/secure-file.ts b/src/shared/secure-file.ts new file mode 100644 index 00000000000..3d662a0806b --- /dev/null +++ b/src/shared/secure-file.ts @@ -0,0 +1,109 @@ +import { execFileSync } from 'child_process' +import { randomBytes } from 'crypto' +import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'fs' +import { dirname } from 'path' + +let cachedWindowsUserSid: string | null | undefined + +export function writeSecureJsonFile(targetPath: string, value: unknown): void { + writeSecureFile(targetPath, JSON.stringify(value, null, 2)) +} + +export function writeSecureFile(targetPath: string, contents: string): void { + const dir = dirname(targetPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }) + } + hardenSecurePath(dir, { isDirectory: true, platform: process.platform }) + + const tmpFile = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp` + try { + writeFileSync(tmpFile, contents, { + encoding: 'utf-8', + mode: 0o600 + }) + hardenSecurePath(tmpFile, { isDirectory: false, platform: process.platform }) + renameSync(tmpFile, targetPath) + // Why: these files carry runtime auth/device credentials; the published + // path must remain current-user only after the atomic rename. + hardenSecurePath(targetPath, { isDirectory: false, platform: process.platform }) + } catch (error) { + rmSync(tmpFile, { force: true }) + throw error + } +} + +export function hardenExistingSecureFile(targetPath: string): void { + const dir = dirname(targetPath) + if (existsSync(dir)) { + hardenSecurePath(dir, { isDirectory: true, platform: process.platform }) + } + if (existsSync(targetPath)) { + hardenSecurePath(targetPath, { isDirectory: false, platform: process.platform }) + } +} + +export function hardenSecurePath( + targetPath: string, + options: { + isDirectory: boolean + platform: NodeJS.Platform + } +): void { + if (options.platform === 'win32') { + bestEffortRestrictWindowsPath(targetPath) + return + } + chmodSync(targetPath, options.isDirectory ? 0o700 : 0o600) +} + +function bestEffortRestrictWindowsPath(targetPath: string): void { + const currentUserSid = getCurrentWindowsUserSid() + if (!currentUserSid) { + return + } + try { + execFileSync( + 'icacls', + [ + targetPath, + '/inheritance:r', + '/grant:r', + `*${currentUserSid}:(F)`, + '*S-1-5-18:(F)', + '*S-1-5-32-544:(F)' + ], + { + stdio: 'ignore', + windowsHide: true, + timeout: 5000 + } + ) + } catch { + // Why: credential-file hardening should not prevent Orca from starting on + // Windows machines where icacls is unavailable or locked down differently. + } +} + +function getCurrentWindowsUserSid(): string | null { + if (cachedWindowsUserSid !== undefined) { + return cachedWindowsUserSid + } + try { + const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + timeout: 5000 + }).trim() + const columns = parseCsvLine(output) + cachedWindowsUserSid = columns[1] ?? null + } catch { + cachedWindowsUserSid = null + } + return cachedWindowsUserSid +} + +function parseCsvLine(line: string): string[] { + return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, '')) +} diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index 5dec28c811e..eb82bbc3980 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -44,6 +44,61 @@ describe('terminal-stream-protocol', () => { }) }) + it('round-trips terminal input and resize frames', () => { + const input = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 11, + seq: 1, + payload: encodeTerminalStreamText('a') + }) + ) + const resize = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 11, + seq: 2, + payload: encodeTerminalStreamJson({ cols: 120, rows: 40 }) + }) + ) + + expect(input?.opcode).toBe(TerminalStreamOpcode.Input) + expect(input ? decodeTerminalStreamText(input.payload) : '').toBe('a') + expect(resize?.opcode).toBe(TerminalStreamOpcode.Resize) + expect(resize && decodeTerminalStreamJson(resize.payload)).toEqual({ cols: 120, rows: 40 }) + }) + + it('round-trips multiplex subscribe and unsubscribe frames', () => { + const subscribe = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 12, + terminal: 'terminal-1', + viewport: { cols: 120, rows: 40 } + }) + }) + ) + const unsubscribe = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Unsubscribe, + streamId: 12, + seq: 2, + payload: new Uint8Array() + }) + ) + + expect(subscribe?.opcode).toBe(TerminalStreamOpcode.Subscribe) + expect(subscribe && decodeTerminalStreamJson(subscribe.payload)).toMatchObject({ + streamId: 12, + terminal: 'terminal-1' + }) + expect(unsubscribe?.opcode).toBe(TerminalStreamOpcode.Unsubscribe) + expect(unsubscribe?.streamId).toBe(12) + }) + it('rejects unknown frame versions and opcodes', () => { const encoded = encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index daa709f415b..1dab528f97e 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -8,7 +8,11 @@ export enum TerminalStreamOpcode { SnapshotChunk = 3, SnapshotEnd = 4, Resized = 5, - Error = 6 + Error = 6, + Input = 7, + Resize = 8, + Subscribe = 9, + Unsubscribe = 10 } export type TerminalStreamFrame = { @@ -82,6 +86,10 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.SnapshotChunk || value === TerminalStreamOpcode.SnapshotEnd || value === TerminalStreamOpcode.Resized || - value === TerminalStreamOpcode.Error + value === TerminalStreamOpcode.Error || + value === TerminalStreamOpcode.Input || + value === TerminalStreamOpcode.Resize || + value === TerminalStreamOpcode.Subscribe || + value === TerminalStreamOpcode.Unsubscribe ) } diff --git a/src/shared/types.ts b/src/shared/types.ts index 7a8b1470a9e..0f9624a10c4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -435,6 +435,7 @@ export type PersistedOpenFile = { worktreeId: string language: string isPreview?: boolean + runtimeEnvironmentId?: string } export type WorkspaceSessionState = { @@ -1382,6 +1383,9 @@ export type GlobalSettings = { * configuration surface and edge cases (conflicts with existing paths, * cleanup on worktree delete) are still being worked out. */ experimentalWorktreeSymlinks: boolean + /** Active non-local runtime environment for client-routed RPC. `null` + * preserves the current local desktop behavior. */ + activeRuntimeEnvironmentId?: string | null /** GitHub Project mode state — pinned/recent/active project, last selected * view per project. Optional because profiles created before this feature * landed won't have the key; `getDefaultSettings()` hydrates the empty diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 40bb2345322..d008f625330 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -125,7 +125,8 @@ const persistedOpenFileSchema = z.object({ relativePath: z.string(), worktreeId: z.string(), language: z.string(), - isPreview: z.boolean().optional() + isPreview: z.boolean().optional(), + runtimeEnvironmentId: z.string().optional() }) // ─── Browser ────────────────────────────────────────────────────────