From cc66e120eb01f6080ec22cab40060b9c87e84645 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:23:09 -0400 Subject: [PATCH] feat: Add SSH remote support (beta) (#590) --- .github/workflows/pr.yml | 3 + config/tsconfig.node.json | 8 +- config/tsconfig.relay.json | 10 + package.json | 8 +- pnpm-lock.yaml | 79 ++ scripts/build-relay.mjs | 55 ++ src/main/ipc/filesystem-mutations.ts | 61 +- src/main/ipc/filesystem-watcher.ts | 74 +- src/main/ipc/filesystem.ts | 571 +++++++---- src/main/ipc/pty.test.ts | 142 +-- src/main/ipc/pty.ts | 896 ++++-------------- src/main/ipc/repos-remote.test.ts | 192 ++++ src/main/ipc/repos.ts | 149 ++- src/main/ipc/ssh-auth-helpers.ts | 68 ++ src/main/ipc/ssh-browse.ts | 108 +++ src/main/ipc/ssh-relay-helpers.ts | 187 ++++ src/main/ipc/ssh.test.ts | 287 ++++++ src/main/ipc/ssh.ts | 299 ++++++ src/main/ipc/worktree-hooks.ts | 131 +++ src/main/ipc/worktree-remote.ts | 248 +++++ src/main/ipc/worktrees.ts | 187 ++-- src/main/persistence.ts | 40 +- src/main/providers/local-pty-provider.test.ts | 272 ++++++ src/main/providers/local-pty-provider.ts | 441 +++++++++ src/main/providers/local-pty-shell-ready.ts | 237 +++++ src/main/providers/local-pty-utils.ts | 168 ++++ src/main/providers/provider-dispatch.test.ts | 175 ++++ src/main/providers/ssh-filesystem-dispatch.ts | 18 + .../providers/ssh-filesystem-provider.test.ts | 179 ++++ src/main/providers/ssh-filesystem-provider.ts | 106 +++ src/main/providers/ssh-git-dispatch.ts | 15 + src/main/providers/ssh-git-provider.test.ts | 168 ++++ src/main/providers/ssh-git-provider.ts | 175 ++++ src/main/providers/ssh-pty-provider.test.ts | 191 ++++ src/main/providers/ssh-pty-provider.ts | 162 ++++ src/main/providers/types.ts | 122 +++ src/main/ssh/relay-protocol.test.ts | 222 +++++ src/main/ssh/relay-protocol.ts | 205 ++++ src/main/ssh/ssh-channel-multiplexer.test.ts | 222 +++++ src/main/ssh/ssh-channel-multiplexer.ts | 284 ++++++ src/main/ssh/ssh-config-parser.test.ts | 211 +++++ src/main/ssh/ssh-config-parser.ts | 150 +++ src/main/ssh/ssh-connection-manager.ts | 84 ++ src/main/ssh/ssh-connection-store.test.ts | 141 +++ src/main/ssh/ssh-connection-store.ts | 48 + src/main/ssh/ssh-connection-utils.ts | 299 ++++++ src/main/ssh/ssh-connection.test.ts | 221 +++++ src/main/ssh/ssh-connection.ts | 371 ++++++++ src/main/ssh/ssh-port-forward.test.ts | 129 +++ src/main/ssh/ssh-port-forward.ts | 117 +++ src/main/ssh/ssh-relay-deploy-helpers.ts | 244 +++++ src/main/ssh/ssh-relay-deploy.ts | 255 +++++ src/main/ssh/ssh-system-fallback.test.ts | 133 +++ src/main/ssh/ssh-system-fallback.ts | 101 ++ .../window/attach-main-window-services.ts | 2 + src/preload/api-types.d.ts | 124 ++- src/preload/index.d.ts | 31 + src/preload/index.ts | 220 ++++- src/relay/context.ts | 70 ++ src/relay/dispatcher.test.ts | 219 +++++ src/relay/dispatcher.ts | 170 ++++ src/relay/fs-handler-utils.ts | 288 ++++++ src/relay/fs-handler.test.ts | 225 +++++ src/relay/fs-handler.ts | 276 ++++++ src/relay/git-exec-validator.test.ts | 177 ++++ src/relay/git-exec-validator.ts | 135 +++ src/relay/git-handler-ops.ts | 266 ++++++ src/relay/git-handler-utils.ts | 324 +++++++ src/relay/git-handler.test.ts | 333 +++++++ src/relay/git-handler.ts | 348 +++++++ src/relay/integration.test.ts | 403 ++++++++ src/relay/protocol.ts | 137 +++ src/relay/pty-handler.test.ts | 276 ++++++ src/relay/pty-handler.ts | 355 +++++++ src/relay/pty-shell-utils.ts | 133 +++ src/relay/relay.ts | 99 ++ src/relay/subprocess-test-utils.ts | 170 ++++ src/relay/subprocess.test.ts | 202 ++++ .../components/editor/CombinedDiffViewer.tsx | 13 +- .../src/components/editor/EditorPanel.tsx | 38 +- .../src/components/editor/MonacoEditor.tsx | 8 +- .../editor/editor-autosave-controller.ts | 8 +- .../src/components/editor/useLocalImageSrc.ts | 21 +- .../src/components/repo/RepoCombobox.tsx | 38 +- .../components/right-sidebar/FileExplorer.tsx | 2 +- .../src/components/right-sidebar/Search.tsx | 3 + .../right-sidebar/SourceControl.tsx | 28 +- .../right-sidebar/useFileDeletion.ts | 4 +- .../right-sidebar/useFileExplorerDragDrop.ts | 4 +- .../useFileExplorerInlineInput.ts | 9 +- .../right-sidebar/useFileExplorerTree.ts | 9 +- .../right-sidebar/useFileExplorerWatch.ts | 8 +- .../right-sidebar/useGitStatusPolling.ts | 10 +- .../src/components/settings/Settings.tsx | 24 +- .../components/settings/SettingsSection.tsx | 13 +- .../components/settings/SettingsSidebar.tsx | 15 +- .../src/components/settings/SshPane.tsx | 306 ++++++ .../src/components/settings/SshTargetCard.tsx | 158 +++ .../src/components/settings/SshTargetForm.tsx | 129 +++ .../src/components/sidebar/AddRepoDialog.tsx | 230 ++--- .../src/components/sidebar/AddRepoSteps.tsx | 367 +++++++ .../components/sidebar/NonGitFolderDialog.tsx | 22 +- .../components/sidebar/RemoteFileBrowser.tsx | 201 ++++ .../sidebar/SshDisconnectedDialog.tsx | 111 +++ .../src/components/sidebar/WorktreeCard.tsx | 578 +++++------ .../sidebar/WorktreeCardHelpers.tsx | 67 ++ .../components/sidebar/WorktreeCardMeta.tsx | 158 +++ .../components/terminal-pane/bell-detector.ts | 61 ++ .../terminal-pane/pty-connection.ts | 9 + .../terminal-pane/pty-dispatcher.ts | 163 ++++ .../components/terminal-pane/pty-transport.ts | 413 ++------ .../terminal-pane/terminal-link-handlers.ts | 9 +- src/renderer/src/hooks/ipc-tab-switch.ts | 59 ++ src/renderer/src/hooks/resolve-zoom-target.ts | 49 + src/renderer/src/hooks/useGlobalFileDrop.ts | 9 +- src/renderer/src/hooks/useIpcEvents.test.ts | 7 + src/renderer/src/hooks/useIpcEvents.ts | 141 +-- src/renderer/src/lib/connection-context.ts | 20 + src/renderer/src/store/index.ts | 4 +- src/renderer/src/store/slices/ssh.ts | 35 + .../slices/store-session-cascades.test.ts | 4 +- .../src/store/slices/store-test-helpers.ts | 4 +- src/renderer/src/store/slices/tabs.test.ts | 4 +- src/renderer/src/store/types.ts | 4 +- src/shared/constants.ts | 3 +- src/shared/ssh-types.test.ts | 64 ++ src/shared/ssh-types.ts | 35 + src/shared/types.ts | 4 + tsconfig.json | 6 +- 129 files changed, 16181 insertions(+), 2160 deletions(-) create mode 100644 config/tsconfig.relay.json create mode 100644 scripts/build-relay.mjs create mode 100644 src/main/ipc/repos-remote.test.ts create mode 100644 src/main/ipc/ssh-auth-helpers.ts create mode 100644 src/main/ipc/ssh-browse.ts create mode 100644 src/main/ipc/ssh-relay-helpers.ts create mode 100644 src/main/ipc/ssh.test.ts create mode 100644 src/main/ipc/ssh.ts create mode 100644 src/main/ipc/worktree-hooks.ts create mode 100644 src/main/ipc/worktree-remote.ts create mode 100644 src/main/providers/local-pty-provider.test.ts create mode 100644 src/main/providers/local-pty-provider.ts create mode 100644 src/main/providers/local-pty-shell-ready.ts create mode 100644 src/main/providers/local-pty-utils.ts create mode 100644 src/main/providers/provider-dispatch.test.ts create mode 100644 src/main/providers/ssh-filesystem-dispatch.ts create mode 100644 src/main/providers/ssh-filesystem-provider.test.ts create mode 100644 src/main/providers/ssh-filesystem-provider.ts create mode 100644 src/main/providers/ssh-git-dispatch.ts create mode 100644 src/main/providers/ssh-git-provider.test.ts create mode 100644 src/main/providers/ssh-git-provider.ts create mode 100644 src/main/providers/ssh-pty-provider.test.ts create mode 100644 src/main/providers/ssh-pty-provider.ts create mode 100644 src/main/providers/types.ts create mode 100644 src/main/ssh/relay-protocol.test.ts create mode 100644 src/main/ssh/relay-protocol.ts create mode 100644 src/main/ssh/ssh-channel-multiplexer.test.ts create mode 100644 src/main/ssh/ssh-channel-multiplexer.ts create mode 100644 src/main/ssh/ssh-config-parser.test.ts create mode 100644 src/main/ssh/ssh-config-parser.ts create mode 100644 src/main/ssh/ssh-connection-manager.ts create mode 100644 src/main/ssh/ssh-connection-store.test.ts create mode 100644 src/main/ssh/ssh-connection-store.ts create mode 100644 src/main/ssh/ssh-connection-utils.ts create mode 100644 src/main/ssh/ssh-connection.test.ts create mode 100644 src/main/ssh/ssh-connection.ts create mode 100644 src/main/ssh/ssh-port-forward.test.ts create mode 100644 src/main/ssh/ssh-port-forward.ts create mode 100644 src/main/ssh/ssh-relay-deploy-helpers.ts create mode 100644 src/main/ssh/ssh-relay-deploy.ts create mode 100644 src/main/ssh/ssh-system-fallback.test.ts create mode 100644 src/main/ssh/ssh-system-fallback.ts create mode 100644 src/relay/context.ts create mode 100644 src/relay/dispatcher.test.ts create mode 100644 src/relay/dispatcher.ts create mode 100644 src/relay/fs-handler-utils.ts create mode 100644 src/relay/fs-handler.test.ts create mode 100644 src/relay/fs-handler.ts create mode 100644 src/relay/git-exec-validator.test.ts create mode 100644 src/relay/git-exec-validator.ts create mode 100644 src/relay/git-handler-ops.ts create mode 100644 src/relay/git-handler-utils.ts create mode 100644 src/relay/git-handler.test.ts create mode 100644 src/relay/git-handler.ts create mode 100644 src/relay/integration.test.ts create mode 100644 src/relay/protocol.ts create mode 100644 src/relay/pty-handler.test.ts create mode 100644 src/relay/pty-handler.ts create mode 100644 src/relay/pty-shell-utils.ts create mode 100644 src/relay/relay.ts create mode 100644 src/relay/subprocess-test-utils.ts create mode 100644 src/relay/subprocess.test.ts create mode 100644 src/renderer/src/components/settings/SshPane.tsx create mode 100644 src/renderer/src/components/settings/SshTargetCard.tsx create mode 100644 src/renderer/src/components/settings/SshTargetForm.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoSteps.tsx create mode 100644 src/renderer/src/components/sidebar/RemoteFileBrowser.tsx create mode 100644 src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardHelpers.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardMeta.tsx create mode 100644 src/renderer/src/components/terminal-pane/bell-detector.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-dispatcher.ts create mode 100644 src/renderer/src/hooks/ipc-tab-switch.ts create mode 100644 src/renderer/src/hooks/resolve-zoom-target.ts create mode 100644 src/renderer/src/lib/connection-context.ts create mode 100644 src/renderer/src/store/slices/ssh.ts create mode 100644 src/shared/ssh-types.test.ts create mode 100644 src/shared/ssh-types.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 57de6794..9508e3d7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,6 +16,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Install native build tools + run: sudo apt-get update && sudo apt-get install -y build-essential python3 + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index a417312a..640da09f 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -1,6 +1,12 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["../electron.vite.config.*", "../src/main/**/*", "../src/preload/**/*", "../src/shared/**/*"], + "include": [ + "../electron.vite.config.*", + "../src/main/**/*", + "../src/preload/**/*", + "../src/shared/**/*", + "../src/relay/**/*" + ], "compilerOptions": { "composite": true, "types": ["electron-vite/node"] diff --git a/config/tsconfig.relay.json b/config/tsconfig.relay.json new file mode 100644 index 00000000..247b9ac7 --- /dev/null +++ b/config/tsconfig.relay.json @@ -0,0 +1,10 @@ +{ + "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", + "include": ["../src/relay/**/*"], + "exclude": ["../src/relay/integration.test.ts"], + "compilerOptions": { + "composite": true, + "rootDir": "../src", + "outDir": "../out/relay" + } +} diff --git a/package.json b/package.json index d277a9ef..1610caf8 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,11 @@ "typecheck": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false", "start": "electron-vite preview", "dev": "node config/scripts/run-electron-vite-dev.mjs", + "build:relay": "node scripts/build-relay.mjs", "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", - "build": "pnpm run typecheck && pnpm run build:electron-vite && pnpm run build:cli", - "build:release": "pnpm run build:electron-vite && pnpm run build:cli", + "build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli", + "build:release": "pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli", "postinstall": "pnpm rebuild electron && electron-builder install-app-deps", "build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --win", @@ -89,6 +90,7 @@ "shadcn": "^4.1.0", "simple-git": "^3.33.0", "sonner": "^2.0.7", + "ssh2": "^1.17.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "zustand": "^5.0.12" @@ -99,6 +101,7 @@ "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/ssh2": "^1.15.5", "@typescript/native-preview": "7.0.0-dev.20260406.1", "@vitejs/plugin-react": "^5.2.0", "electron": "^41.0.3", @@ -129,6 +132,7 @@ "pnpm": { "onlyBuiltDependencies": [ "@parcel/watcher", + "cpu-features", "electron", "esbuild", "node-pty" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6cec942..6c4215c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,6 +163,9 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + ssh2: + specifier: ^1.17.0 + version: 1.17.0 tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -188,6 +191,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/ssh2': + specifier: ^1.15.5 + version: 1.15.5 '@typescript/native-preview': specifier: 7.0.0-dev.20260406.1 version: 7.0.0-dev.20260406.1 @@ -2644,6 +2650,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} @@ -2669,6 +2678,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -2872,6 +2884,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -2920,6 +2935,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bippy@0.5.32: resolution: {integrity: sha512-yt1mC8eReTxjfg41YBZdN4PvsDwHFWxltoiQX0Q+Htlbf41aSniopb7ECZits01HwNAvXEh69RGk/ImlswDTEw==} peerDependencies: @@ -2964,6 +2982,10 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + builder-util-runtime@9.5.1: resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} engines: {node: '>=12.0.0'} @@ -3208,6 +3230,10 @@ packages: typescript: optional: true + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + crc@3.8.0: resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} @@ -4717,6 +4743,9 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + nan@2.26.2: + resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5425,6 +5454,10 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + ssri@12.0.0: resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5625,6 +5658,9 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -5648,6 +5684,9 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -8256,6 +8295,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 @@ -8286,6 +8329,10 @@ snapshots: dependencies: '@types/node': 25.5.0 + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@types/statuses@2.0.6': {} '@types/trusted-types@2.0.7': @@ -8513,6 +8560,10 @@ snapshots: dependencies: tslib: 2.8.1 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assert-plus@1.0.0: optional: true @@ -8543,6 +8594,10 @@ snapshots: baseline-browser-mapping@2.10.10: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + bippy@0.5.32(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/react-reconciler': 0.28.9(@types/react@19.2.14) @@ -8607,6 +8662,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buildcheck@0.0.7: + optional: true + builder-util-runtime@9.5.1: dependencies: debug: 4.4.3 @@ -8849,6 +8907,12 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.26.2 + optional: true + crc@3.8.0: dependencies: buffer: 5.7.1 @@ -10719,6 +10783,9 @@ snapshots: mute-stream@2.0.0: {} + nan@2.26.2: + optional: true + nanoid@3.3.11: {} negotiator@1.0.0: {} @@ -11685,6 +11752,14 @@ snapshots: sprintf-js@1.1.3: optional: true + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.26.2 + ssri@12.0.0: dependencies: minipass: 7.1.3 @@ -11871,6 +11946,8 @@ snapshots: tw-animate-css@1.4.0: {} + tweetnacl@0.14.5: {} + type-fest@0.13.1: optional: true @@ -11890,6 +11967,8 @@ snapshots: ufo@1.6.3: {} + undici-types@5.26.5: {} + undici-types@7.16.0: {} undici-types@7.18.2: {} diff --git a/scripts/build-relay.mjs b/scripts/build-relay.mjs new file mode 100644 index 00000000..8a794a97 --- /dev/null +++ b/scripts/build-relay.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Bundle the relay daemon into a single relay.js file per platform. + * + * The relay runs on remote hosts via `node relay.js`, so it must be a + * self-contained CommonJS bundle with no external dependencies beyond + * Node.js built-ins. Native addons (node-pty, @parcel/watcher) are + * marked external and expected to be installed on the remote or + * gracefully degraded. + */ +import { build } from 'esbuild' +import { createHash } from 'crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = join(__dirname, '..') +const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') + +const PLATFORMS = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64'] + +const RELAY_VERSION = '0.1.0' + +for (const platform of PLATFORMS) { + const outDir = join(ROOT, 'out', 'relay', platform) + mkdirSync(outDir, { recursive: true }) + + await build({ + entryPoints: [RELAY_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outDir, 'relay.js'), + // Native addons cannot be bundled — they must exist on the remote host. + // The relay gracefully degrades when they are absent. + external: ['node-pty', '@parcel/watcher'], + sourcemap: false, + minify: true, + define: { + 'process.env.NODE_ENV': '"production"' + } + }) + + // Why: include a content hash so the deploy check detects code changes + // even when RELAY_VERSION hasn't been bumped (common during development). + const relayContent = readFileSync(join(outDir, 'relay.js')) + const hash = createHash('sha256').update(relayContent).digest('hex').slice(0, 12) + writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`) + + console.log(`Built relay for ${platform} → ${outDir}/relay.js`) +} + +console.log('Relay build complete.') diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 1754017b..20c69267 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -3,6 +3,7 @@ import { lstat, mkdir, rename, writeFile } from 'fs/promises' import { basename, dirname } from 'path' import type { Store } from '../persistence' import { resolveAuthorizedPath, isENOENT } from './filesystem-auth' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' /** * Re-throw filesystem errors with user-friendly messages. @@ -49,29 +50,59 @@ async function assertNotExists(targetPath: string): Promise { * Deletion is handled separately via `fs:deletePath` (shell.trashItem). */ export function registerFilesystemMutationHandlers(store: Store): void { - ipcMain.handle('fs:createFile', async (_event, args: { filePath: string }): Promise => { - const filePath = await resolveAuthorizedPath(args.filePath, store) - await mkdir(dirname(filePath), { recursive: true }) - try { - // Use the 'wx' flag for atomic create-if-not-exists, avoiding TOCTOU races - await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) - } catch (error) { - rethrowWithUserMessage(error, filePath) + ipcMain.handle( + 'fs:createFile', + async (_event, args: { filePath: 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.createFile(args.filePath) + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + await mkdir(dirname(filePath), { recursive: true }) + try { + // Use the 'wx' flag for atomic create-if-not-exists, avoiding TOCTOU races + await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) + } catch (error) { + rethrowWithUserMessage(error, filePath) + } } - }) + ) - ipcMain.handle('fs:createDir', async (_event, args: { dirPath: string }): Promise => { - const dirPath = await resolveAuthorizedPath(args.dirPath, store) - await assertNotExists(dirPath) - await mkdir(dirPath, { recursive: true }) - }) + ipcMain.handle( + 'fs:createDir', + async (_event, args: { dirPath: 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.createDir(args.dirPath) + } + const dirPath = await resolveAuthorizedPath(args.dirPath, store) + await assertNotExists(dirPath) + await mkdir(dirPath, { recursive: true }) + } + ) // Note: fs.rename throws EXDEV if old and new paths are on different // filesystems/volumes. This is unlikely since both paths are under the same // workspace root, but a cross-drive rename would surface as an IPC error. ipcMain.handle( 'fs:rename', - async (_event, args: { oldPath: string; newPath: string }): Promise => { + async ( + _event, + args: { oldPath: string; newPath: 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.rename(args.oldPath, args.newPath) + } const oldPath = await resolveAuthorizedPath(args.oldPath, store) const newPath = await resolveAuthorizedPath(args.newPath, store) await assertNotExists(newPath) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 4d6d7c04..4fcf8648 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -1,3 +1,8 @@ +/* eslint-disable max-lines -- Why: filesystem-watcher centralizes native +(@parcel/watcher), WSL (inotifywait), and SSH remote watcher lifecycles in +one module so subscription/cleanup invariants stay auditable from a single +file. Splitting by transport would scatter the shared debounce/coalesce +helpers and the common batch-flush path across three files. */ import { ipcMain, type WebContents } from 'electron' import * as path from 'path' import { stat } from 'fs/promises' @@ -6,6 +11,7 @@ import type { FsChangeEvent, FsChangedPayload } from '../../shared/types' import { isWslPath } from '../wsl' import { createWslWatcher } from './filesystem-watcher-wsl' import type { WatchedRoot } from './filesystem-watcher-wsl' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' // ── Ignore patterns ────────────────────────────────────────────────── // Why: high-churn directories are suppressed at the native watcher level @@ -385,20 +391,65 @@ function unsubscribe(worktreePath: string, senderId: number): void { } } +// ── Remote watcher state ───────────────────────────────────────────── +// Key: `${connectionId}:${worktreePath}`, Value: unwatch function +const remoteWatchers = new Map void>() + // ── Public API ─────────────────────────────────────────────────────── export function registerFilesystemWatcherHandlers(): void { ipcMain.handle( 'fs:watchWorktree', - async (event, args: { worktreePath: string }): Promise => { + async (event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + if (!provider) { + throw new Error(`No filesystem provider for connection "${args.connectionId}"`) + } + const key = `${args.connectionId}:${args.worktreePath}` + if (remoteWatchers.has(key)) { + return + } + + const unwatch = await provider.watch(args.worktreePath, (events) => { + if (!event.sender.isDestroyed()) { + event.sender.send('fs:changed', { + worktreePath: args.worktreePath, + events + } satisfies FsChangedPayload) + } + }) + remoteWatchers.set(key, unwatch) + + event.sender.once('destroyed', () => { + const unwatchFn = remoteWatchers.get(key) + if (unwatchFn) { + unwatchFn() + remoteWatchers.delete(key) + } + }) + return + } await subscribe(args.worktreePath, event.sender) } ) - ipcMain.handle('fs:unwatchWorktree', (_event, args: { worktreePath: string }): void => { - const senderId = _event.sender.id - unsubscribe(args.worktreePath, senderId) - }) + ipcMain.handle( + 'fs:unwatchWorktree', + (_event, args: { worktreePath: string; connectionId?: string }): void => { + if (args.connectionId) { + const key = `${args.connectionId}:${args.worktreePath}` + const unwatchFn = remoteWatchers.get(key) + if (unwatchFn) { + unwatchFn() + remoteWatchers.delete(key) + } + return + } + const senderId = _event.sender.id + unsubscribe(args.worktreePath, senderId) + } + ) } /** Tear down all watchers on app shutdown. */ @@ -414,4 +465,17 @@ export async function closeAllWatchers(): Promise { } } watchedRoots.clear() + + // Why: remote watchers are tracked separately from local @parcel/watcher + // subscriptions. Without cleaning them up here, their unwatch callbacks + // would never fire, leaving the relay polling for FS changes after the + // app has shut down. + for (const [key, unwatchFn] of remoteWatchers) { + try { + unwatchFn() + } catch (err) { + console.error(`[filesystem-watcher] remote unwatch error for ${key}:`, err) + } + } + remoteWatchers.clear() } diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index f38db504..a81563d5 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -41,6 +41,8 @@ import { listQuickOpenFiles } from './filesystem-list-files' import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { searchWithGitGrep } from './filesystem-search-git' import { checkRgAvailable } from './rg-availability' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_SEARCH_MAX_RESULTS = 2000 @@ -80,30 +82,46 @@ export function registerFilesystemHandlers(store: Store): void { const activeTextSearches = new Map() // ─── Filesystem ───────────────────────────────────────── - ipcMain.handle('fs:readDir', async (_event, args: { dirPath: string }): Promise => { - const dirPath = await resolveAuthorizedPath(args.dirPath, store) - const entries = await readdir(dirPath, { withFileTypes: true }) - return entries - .map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - })) - .sort((a, b) => { - // Directories first, then alphabetical - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 + ipcMain.handle( + 'fs:readDir', + async (_event, args: { dirPath: 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 a.name.localeCompare(b.name) - }) - }) + return provider.readDir(args.dirPath) + } + const dirPath = await resolveAuthorizedPath(args.dirPath, store) + const entries = await readdir(dirPath, { withFileTypes: true }) + return entries + .map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink() + })) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + } + ) ipcMain.handle( 'fs:readFile', async ( _event, - args: { filePath: string } + args: { filePath: string; connectionId?: string } ): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + if (!provider) { + throw new Error(`No filesystem provider for connection "${args.connectionId}"`) + } + return provider.readFile(args.filePath) + } const filePath = await resolveAuthorizedPath(args.filePath, store) const stats = await stat(filePath) if (stats.size > MAX_FILE_SIZE) { @@ -136,7 +154,17 @@ export function registerFilesystemHandlers(store: Store): void { ipcMain.handle( 'fs:writeFile', - async (_event, args: { filePath: string; content: string }): Promise => { + async ( + _event, + args: { filePath: string; content: 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.writeFile(args.filePath, args.content) + } const filePath = await resolveAuthorizedPath(args.filePath, store) try { @@ -154,21 +182,31 @@ export function registerFilesystemHandlers(store: Store): void { } ) - ipcMain.handle('fs:deletePath', async (_event, args: { targetPath: string }): Promise => { - const targetPath = await resolveAuthorizedPath(args.targetPath, store) - - // Why: once auto-refresh exists, an external delete can race with a - // UI-initiated delete. Swallowing ENOENT keeps the action idempotent - // from the user's perspective (design §7.1). - try { - await shell.trashItem(targetPath) - } catch (error) { - if (isENOENT(error)) { - return + ipcMain.handle( + 'fs:deletePath', + async (_event, args: { targetPath: 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.deletePath(args.targetPath) + } + const targetPath = await resolveAuthorizedPath(args.targetPath, store) + + // Why: once auto-refresh exists, an external delete can race with a + // UI-initiated delete. Swallowing ENOENT keeps the action idempotent + // from the user's perspective (design §7.1). + try { + await shell.trashItem(targetPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error } - throw error } - }) + ) registerFilesystemMutationHandlers(store) @@ -180,8 +218,16 @@ export function registerFilesystemHandlers(store: Store): void { 'fs:stat', async ( _event, - args: { filePath: string } + args: { filePath: string; connectionId?: string } ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + if (!provider) { + throw new Error(`No filesystem provider for connection "${args.connectionId}"`) + } + const s = await provider.stat(args.filePath) + return { size: s.size, isDirectory: s.type === 'directory', mtime: s.mtime } + } const filePath = await resolveAuthorizedPath(args.filePath, store) const stats = await stat(filePath) return { @@ -193,190 +239,218 @@ export function registerFilesystemHandlers(store: Store): void { ) // ─── Search ──────────────────────────────────────────── - ipcMain.handle('fs:search', async (event, args: SearchOptions): Promise => { - const rootPath = await resolveAuthorizedPath(args.rootPath, store) - const maxResults = Math.max( - 1, - Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) - ) - const searchKey = `${event.sender.id}:${rootPath}` - - // Why: checking rg availability upfront avoids a race condition where - // spawn('rg') emits 'close' before 'error' on some platforms, causing - // the handler to resolve with empty results before the git-grep - // fallback can run. The result is cached after the first check. - const rgAvailable = await checkRgAvailable(rootPath) - if (!rgAvailable) { - return searchWithGitGrep(rootPath, args, maxResults) - } - - return new Promise((resolvePromise) => { - const rgArgs: string[] = [ - '--json', - '--hidden', - '--glob', - '!.git', - '--max-count', - String(MAX_MATCHES_PER_FILE), - '--max-filesize', - `${Math.floor(MAX_FILE_SIZE / 1024 / 1024)}M` - ] - - if (!args.caseSensitive) { - rgArgs.push('--ignore-case') - } - if (args.wholeWord) { - rgArgs.push('--word-regexp') - } - if (!args.useRegex) { - rgArgs.push('--fixed-strings') - } - if (args.includePattern) { - for (const pat of args.includePattern - .split(',') - .map((s) => s.trim()) - .filter(Boolean)) { - rgArgs.push('--glob', pat) + ipcMain.handle( + 'fs:search', + async (event, args: SearchOptions & { 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.search(args) } - if (args.excludePattern) { - for (const pat of args.excludePattern - .split(',') - .map((s) => s.trim()) - .filter(Boolean)) { - rgArgs.push('--glob', `!${pat}`) - } + const rootPath = await resolveAuthorizedPath(args.rootPath, store) + const maxResults = Math.max( + 1, + Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) + ) + const searchKey = `${event.sender.id}:${rootPath}` + + // Why: checking rg availability upfront avoids a race condition where + // spawn('rg') emits 'close' before 'error' on some platforms, causing + // the handler to resolve with empty results before the git-grep + // fallback can run. The result is cached after the first check. + const rgAvailable = await checkRgAvailable(rootPath) + if (!rgAvailable) { + return searchWithGitGrep(rootPath, args, maxResults) } - rgArgs.push('--', args.query, rootPath) + return new Promise((resolvePromise) => { + const rgArgs: string[] = [ + '--json', + '--hidden', + '--glob', + '!.git', + '--max-count', + String(MAX_MATCHES_PER_FILE), + '--max-filesize', + `${Math.floor(MAX_FILE_SIZE / 1024 / 1024)}M` + ] - // Why: search requests are fired on each query/options change. If the - // previous ripgrep process keeps running, it can continue streaming and - // parsing thousands of matches on the Electron main thread after the UI - // no longer cares about that result, which is exactly the freeze users - // experience in large repos. - activeTextSearches.get(searchKey)?.kill() - - const fileMap = new Map() - let totalMatches = 0 - let truncated = false - let stdoutBuffer = '' - let resolved = false - let child: ChildProcess | null = null - - const resolveOnce = (): void => { - if (resolved) { - return + if (!args.caseSensitive) { + rgArgs.push('--ignore-case') } - resolved = true - if (activeTextSearches.get(searchKey) === child) { - activeTextSearches.delete(searchKey) + if (args.wholeWord) { + rgArgs.push('--word-regexp') } - clearTimeout(killTimeout) - resolvePromise({ - files: Array.from(fileMap.values()), - totalMatches, - truncated - }) - } - - const processLine = (line: string): void => { - if (!line || totalMatches >= maxResults) { - return + if (!args.useRegex) { + rgArgs.push('--fixed-strings') + } + if (args.includePattern) { + for (const pat of args.includePattern + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + rgArgs.push('--glob', pat) + } + } + if (args.excludePattern) { + for (const pat of args.excludePattern + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + rgArgs.push('--glob', `!${pat}`) + } } - try { - const msg = JSON.parse(line) - if (msg.type !== 'match') { + rgArgs.push('--', args.query, rootPath) + + // Why: search requests are fired on each query/options change. If the + // previous ripgrep process keeps running, it can continue streaming and + // parsing thousands of matches on the Electron main thread after the UI + // no longer cares about that result, which is exactly the freeze users + // experience in large repos. + activeTextSearches.get(searchKey)?.kill() + + const fileMap = new Map() + let totalMatches = 0 + let truncated = false + let stdoutBuffer = '' + let resolved = false + let child: ChildProcess | null = null + + const resolveOnce = (): void => { + if (resolved) { + return + } + resolved = true + if (activeTextSearches.get(searchKey) === child) { + activeTextSearches.delete(searchKey) + } + clearTimeout(killTimeout) + resolvePromise({ + files: Array.from(fileMap.values()), + totalMatches, + truncated + }) + } + + const processLine = (line: string): void => { + if (!line || totalMatches >= maxResults) { return } - const data = msg.data - // Why: when rg runs inside WSL, output paths are Linux-native - // (e.g. /home/user/repo/src/file.ts). Translate them back to - // Windows UNC paths so path.relative() and Node fs APIs work. - const wslInfo = parseWslPath(rootPath) - const absPath: string = wslInfo - ? toWindowsWslPath(data.path.text, wslInfo.distro) - : data.path.text - const relPath = normalizeRelativePath(relative(rootPath, absPath)) - - let fileResult = fileMap.get(absPath) - if (!fileResult) { - fileResult = { filePath: absPath, relativePath: relPath, matches: [] } - fileMap.set(absPath, fileResult) - } - - for (const sub of data.submatches) { - fileResult.matches.push({ - line: data.line_number, - column: sub.start + 1, - matchLength: sub.end - sub.start, - lineContent: data.lines.text.replace(/\n$/, '') - }) - totalMatches++ - if (totalMatches >= maxResults) { - truncated = true - child?.kill() - break + try { + const msg = JSON.parse(line) + if (msg.type !== 'match') { + return } + + const data = msg.data + // Why: when rg runs inside WSL, output paths are Linux-native + // (e.g. /home/user/repo/src/file.ts). Translate them back to + // Windows UNC paths so path.relative() and Node fs APIs work. + const wslInfo = parseWslPath(rootPath) + const absPath: string = wslInfo + ? toWindowsWslPath(data.path.text, wslInfo.distro) + : data.path.text + const relPath = normalizeRelativePath(relative(rootPath, absPath)) + + let fileResult = fileMap.get(absPath) + if (!fileResult) { + fileResult = { filePath: absPath, relativePath: relPath, matches: [] } + fileMap.set(absPath, fileResult) + } + + for (const sub of data.submatches) { + fileResult.matches.push({ + line: data.line_number, + column: sub.start + 1, + matchLength: sub.end - sub.start, + lineContent: data.lines.text.replace(/\n$/, '') + }) + totalMatches++ + if (totalMatches >= maxResults) { + truncated = true + child?.kill() + break + } + } + } catch { + // skip malformed JSON lines } - } catch { - // skip malformed JSON lines } - } - const nextChild = wslAwareSpawn('rg', rgArgs, { - cwd: rootPath, - stdio: ['ignore', 'pipe', 'pipe'] - }) - child = nextChild - activeTextSearches.set(searchKey, nextChild) + const nextChild = wslAwareSpawn('rg', rgArgs, { + cwd: rootPath, + stdio: ['ignore', 'pipe', 'pipe'] + }) + child = nextChild + activeTextSearches.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.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('error', () => { + resolveOnce() + }) - nextChild.once('close', () => { - if (stdoutBuffer) { - processLine(stdoutBuffer) - } - resolveOnce() - }) + nextChild.once('close', () => { + if (stdoutBuffer) { + processLine(stdoutBuffer) + } + resolveOnce() + }) - // Why: if the timeout fires, the child is killed and results are partial. - // We must mark them as truncated so the UI can indicate incomplete results. - const killTimeout = setTimeout(() => { - truncated = true - child?.kill() - }, SEARCH_TIMEOUT_MS) - }) - }) + // Why: if the timeout fires, the child is killed and results are partial. + // We must mark them as truncated so the UI can indicate incomplete results. + const killTimeout = setTimeout(() => { + truncated = true + child?.kill() + }, SEARCH_TIMEOUT_MS) + }) + } + ) // ─── List all files (for quick-open) ───────────────────── ipcMain.handle( 'fs:listFiles', - async (_event, args: { rootPath: string }): Promise => - listQuickOpenFiles(args.rootPath, store) + async (_event, args: { rootPath: 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.listFiles(args.rootPath) + } + return listQuickOpenFiles(args.rootPath, store) + } ) // ─── Git operations ───────────────────────────────────── ipcMain.handle( 'git:status', - async (_event, args: { worktreePath: string }): Promise => { + async ( + _event, + args: { worktreePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getStatus(args.worktreePath) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) return getStatus(worktreePath) } @@ -387,7 +461,17 @@ export function registerFilesystemHandlers(store: Store): void { // operation finishes, without running a full `git status`. ipcMain.handle( 'git:conflictOperation', - async (_event, args: { worktreePath: string }): Promise => { + async ( + _event, + args: { worktreePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.detectConflictOperation(args.worktreePath) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) return detectConflictOperation(worktreePath) } @@ -397,8 +481,15 @@ export function registerFilesystemHandlers(store: Store): void { 'git:diff', async ( _event, - args: { worktreePath: string; filePath: string; staged: boolean } + args: { worktreePath: string; filePath: string; staged: boolean; connectionId?: string } ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getDiff(args.worktreePath, args.filePath, args.staged) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) return getDiff(worktreePath, filePath, args.staged) @@ -409,8 +500,15 @@ export function registerFilesystemHandlers(store: Store): void { 'git:branchCompare', async ( _event, - args: { worktreePath: string; baseRef: string } + args: { worktreePath: string; baseRef: string; connectionId?: string } ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getBranchCompare(args.worktreePath, args.baseRef) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) return getBranchCompare(worktreePath, args.baseRef) } @@ -430,8 +528,29 @@ export function registerFilesystemHandlers(store: Store): void { } filePath: string oldPath?: string + connectionId?: string } ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { + includePatch: true, + filePath: args.filePath, + oldPath: args.oldPath + }) + return ( + results[0] ?? { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const oldPath = args.oldPath @@ -448,7 +567,17 @@ export function registerFilesystemHandlers(store: Store): void { ipcMain.handle( 'git:stage', - async (_event, args: { worktreePath: string; filePath: string }): Promise => { + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.stageFile(args.worktreePath, args.filePath) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) await stageFile(worktreePath, filePath) @@ -457,7 +586,17 @@ export function registerFilesystemHandlers(store: Store): void { ipcMain.handle( 'git:unstage', - async (_event, args: { worktreePath: string; filePath: string }): Promise => { + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.unstageFile(args.worktreePath, args.filePath) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) await unstageFile(worktreePath, filePath) @@ -466,7 +605,17 @@ export function registerFilesystemHandlers(store: Store): void { ipcMain.handle( 'git:discard', - async (_event, args: { worktreePath: string; filePath: string }): Promise => { + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.discardChanges(args.worktreePath, args.filePath) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) await discardChanges(worktreePath, filePath) @@ -475,7 +624,17 @@ export function registerFilesystemHandlers(store: Store): void { ipcMain.handle( 'git:bulkStage', - async (_event, args: { worktreePath: string; filePaths: string[] }): Promise => { + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.bulkStageFiles(args.worktreePath, args.filePaths) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) await bulkStageFiles(worktreePath, filePaths) @@ -484,7 +643,17 @@ export function registerFilesystemHandlers(store: Store): void { ipcMain.handle( 'git:bulkUnstage', - async (_event, args: { worktreePath: string; filePaths: string[] }): Promise => { + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.bulkUnstageFiles(args.worktreePath, args.filePaths) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) await bulkUnstageFiles(worktreePath, filePaths) @@ -495,8 +664,18 @@ export function registerFilesystemHandlers(store: Store): void { 'git:remoteFileUrl', async ( _event, - args: { worktreePath: string; relativePath: string; line: number } + args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } ): Promise => { + // Why: remote repos can't use the local hosted-git-info approach because + // the .git/config lives on the remote. Route through the relay's git.exec + // to fetch the remote URL and build the file link server-side. + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) + } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) return getRemoteFileUrl(worktreePath, args.relativePath, args.line) } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index dd2c1e65..b15ee8be 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -92,7 +92,8 @@ describe('registerPtyHandlers', () => { isDestroyed: () => false, webContents: { on: vi.fn(), - send: vi.fn() + send: vi.fn(), + removeListener: vi.fn() } } @@ -118,12 +119,12 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() - handleMock.mockImplementation((channel, handler) => { + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { handlers.set(channel, handler) }) getPathMock.mockReturnValue('/tmp/orca-user-data') existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue({ isDirectory: () => true }) + statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 }) openCodeBuildPtyEnvMock.mockReturnValue({ ORCA_OPENCODE_HOOK_PORT: '4567', ORCA_OPENCODE_HOOK_TOKEN: 'opencode-token', @@ -140,7 +141,9 @@ describe('registerPtyHandlers', () => { onExit: vi.fn(() => makeDisposable()), write: vi.fn(), resize: vi.fn(), - kill: vi.fn() + kill: vi.fn(), + process: 'zsh', + pid: 12345 }) }) @@ -172,11 +175,11 @@ describe('registerPtyHandlers', () => { } /** Helper: trigger pty:spawn and return the env passed to node-pty. */ - function spawnAndGetEnv( + async function spawnAndGetEnv( argsEnv?: Record, processEnvOverrides?: Record, getSelectedCodexHomePath?: () => string | null - ): Record { + ): Promise> { const savedEnv: Record = {} if (processEnvOverrides) { for (const [k, v] of Object.entries(processEnvOverrides)) { @@ -194,7 +197,7 @@ describe('registerPtyHandlers', () => { // accumulate stale state across calls within one test. handlers.clear() registerPtyHandlers(mainWindow as never, undefined, getSelectedCodexHomePath) - handlers.get('pty:spawn')!(null, { + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, ...(argsEnv ? { env: argsEnv } : {}) @@ -232,35 +235,36 @@ describe('registerPtyHandlers', () => { } describe('spawn environment', () => { - it('defaults LANG to en_US.UTF-8 when not inherited from process.env', () => { - const env = spawnAndGetEnv(undefined, { LANG: undefined }) + it('defaults LANG to en_US.UTF-8 when not inherited from process.env', async () => { + const env = await spawnAndGetEnv(undefined, { LANG: undefined }) expect(env.LANG).toBe('en_US.UTF-8') }) - it('inherits LANG from process.env when already set', () => { - const env = spawnAndGetEnv(undefined, { LANG: 'ja_JP.UTF-8' }) + it('inherits LANG from process.env when already set', async () => { + const env = await spawnAndGetEnv(undefined, { LANG: 'ja_JP.UTF-8' }) expect(env.LANG).toBe('ja_JP.UTF-8') }) - it('lets caller-provided env override LANG', () => { - const env = spawnAndGetEnv({ LANG: 'fr_FR.UTF-8' }) + it('lets caller-provided env override LANG', async () => { + const env = await spawnAndGetEnv({ LANG: 'fr_FR.UTF-8' }) expect(env.LANG).toBe('fr_FR.UTF-8') }) - it('always sets TERM and COLORTERM regardless of env', () => { - const env = spawnAndGetEnv() + it('always sets TERM and COLORTERM regardless of env', async () => { + const env = await spawnAndGetEnv() expect(env.TERM).toBe('xterm-256color') expect(env.COLORTERM).toBe('truecolor') expect(env.TERM_PROGRAM).toBe('Orca') }) - it('injects the selected Codex home into Orca terminal PTYs', () => { - const env = spawnAndGetEnv(undefined, undefined, () => '/tmp/orca-codex-home') + it('injects the selected Codex home into Orca terminal PTYs', async () => { + const env = await spawnAndGetEnv(undefined, undefined, () => '/tmp/orca-codex-home') expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') }) - it('injects the OpenCode hook env into Orca terminal PTYs', () => { - const env = spawnAndGetEnv() + it('injects the OpenCode hook env into Orca terminal PTYs', async () => { + // Why: clear any ambient OPENCODE_CONFIG_DIR so the mock's value is used + const env = await spawnAndGetEnv(undefined, { OPENCODE_CONFIG_DIR: undefined }) expect(openCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1) expect(openCodeBuildPtyEnvMock.mock.calls[0]?.[0]).toEqual(expect.any(String)) expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567') @@ -269,13 +273,17 @@ describe('registerPtyHandlers', () => { expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') }) - it('injects the Pi agent overlay env into Orca terminal PTYs', () => { - const env = spawnAndGetEnv(undefined, { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }) + it('injects the Pi agent overlay env into Orca terminal PTYs', async () => { + const env = await spawnAndGetEnv(undefined, { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }) expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent') expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') }) - it('leaves ambient CODEX_HOME untouched when system default is selected', () => { - const env = spawnAndGetEnv(undefined, { CODEX_HOME: '/tmp/system-codex-home' }, () => null) + it('leaves ambient CODEX_HOME untouched when system default is selected', async () => { + const env = await spawnAndGetEnv( + undefined, + { CODEX_HOME: '/tmp/system-codex-home' }, + () => null + ) expect(env.CODEX_HOME).toBe('/tmp/system-codex-home') }) }) @@ -391,7 +399,7 @@ describe('registerPtyHandlers', () => { }) }) - it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', () => { + it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => { const originalPlatform = process.platform const originalUserProfile = process.env.USERPROFILE @@ -411,13 +419,15 @@ describe('registerPtyHandlers', () => { try { registerPtyHandlers(mainWindow as never) - expect(() => + await expect( handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing' }) - ).toThrow('Working directory "\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing" does not exist.') + ).rejects.toThrow( + 'Working directory "\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing" does not exist.' + ) expect(spawnMock).not.toHaveBeenCalled() } finally { Object.defineProperty(process, 'platform', { @@ -543,7 +553,7 @@ describe('registerPtyHandlers', () => { } }) - it('falls back to a system shell when SHELL points to a missing binary', () => { + it('falls back to a system shell when SHELL points to a missing binary', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -555,7 +565,7 @@ describe('registerPtyHandlers', () => { process.env.SHELL = '/opt/homebrew/bin/bash' registerPtyHandlers(mainWindow as never) - const result = handlers.get('pty:spawn')!(null, { + const result = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' @@ -581,7 +591,7 @@ describe('registerPtyHandlers', () => { } }) - it('falls back when SHELL points to a non-executable binary', () => { + it('falls back when SHELL points to a non-executable binary', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -595,7 +605,7 @@ describe('registerPtyHandlers', () => { process.env.SHELL = '/opt/homebrew/bin/bash' registerPtyHandlers(mainWindow as never) - handlers.get('pty:spawn')!(null, { + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' @@ -620,7 +630,7 @@ describe('registerPtyHandlers', () => { } }) - it('prefers args.env.SHELL and normalizes the child env after fallback', () => { + it('prefers args.env.SHELL and normalizes the child env after fallback', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -632,7 +642,7 @@ describe('registerPtyHandlers', () => { process.env.SHELL = '/bin/bash' registerPtyHandlers(mainWindow as never) - handlers.get('pty:spawn')!(null, { + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp', @@ -661,29 +671,38 @@ describe('registerPtyHandlers', () => { } }) - it('cleans up provider-specific PTY overlays when a PTY is killed', () => { + it('cleans up provider-specific PTY overlays when a PTY is killed', async () => { + let exitCb: ((info: { exitCode: number }) => void) | undefined const proc = { onData: vi.fn(() => makeDisposable()), - onExit: vi.fn(() => makeDisposable()), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return makeDisposable() + }), write: vi.fn(), resize: vi.fn(), - kill: vi.fn() + kill: vi.fn(() => { + // Simulate node-pty behavior: kill triggers onExit callback + exitCb?.({ exitCode: -1 }) + }), + process: 'zsh', + pid: 12345 } spawnMock.mockReturnValue(proc) registerPtyHandlers(mainWindow as never) - const spawnResult = handlers.get('pty:spawn')!(null, { + const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 - }) as { id: string } + })) as { id: string } - handlers.get('pty:kill')!(null, { id: spawnResult.id }) + await handlers.get('pty:kill')!(null, { id: spawnResult.id }) expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id) expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id) }) - it('disposes PTY listeners before manual kill IPC', () => { + it('disposes PTY listeners before manual kill IPC', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() const proc = { @@ -691,14 +710,19 @@ describe('registerPtyHandlers', () => { onExit: vi.fn(() => onExitDisposable), write: vi.fn(), resize: vi.fn(), - kill: vi.fn() + kill: vi.fn(), + process: 'zsh', + pid: 12345 } spawnMock.mockReturnValue(proc) registerPtyHandlers(mainWindow as never) - const spawnResult = handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) as { id: string } + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } - handlers.get('pty:kill')!(null, { id: spawnResult.id }) + await handlers.get('pty:kill')!(null, { id: spawnResult.id }) expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( proc.kill.mock.invocationCallOrder[0] @@ -708,7 +732,7 @@ describe('registerPtyHandlers', () => { ) }) - it('disposes PTY listeners before runtime controller kill', () => { + it('disposes PTY listeners before runtime controller kill', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() const proc = { @@ -716,7 +740,9 @@ describe('registerPtyHandlers', () => { onExit: vi.fn(() => onExitDisposable), write: vi.fn(), resize: vi.fn(), - kill: vi.fn() + kill: vi.fn(), + process: 'zsh', + pid: 12345 } const runtime = { setPtyController: vi.fn(), @@ -727,7 +753,10 @@ describe('registerPtyHandlers', () => { spawnMock.mockReturnValue(proc) registerPtyHandlers(mainWindow as never, runtime as never) - const spawnResult = handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) as { id: string } + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } const runtimeController = runtime.setPtyController.mock.calls[0]?.[0] as { kill: (ptyId: string) => boolean } @@ -741,7 +770,7 @@ describe('registerPtyHandlers', () => { ) }) - it('disposes PTY listeners before did-finish-load orphan cleanup', () => { + it('disposes PTY listeners before did-finish-load orphan cleanup', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() const proc = { @@ -749,7 +778,9 @@ describe('registerPtyHandlers', () => { onExit: vi.fn(() => onExitDisposable), write: vi.fn(), resize: vi.fn(), - kill: vi.fn() + kill: vi.fn(), + process: 'zsh', + pid: 12345 } const runtime = { setPtyController: vi.fn(), @@ -764,7 +795,7 @@ describe('registerPtyHandlers', () => { ([eventName]) => eventName === 'did-finish-load' )?.[1] as (() => void) | undefined expect(didFinishLoad).toBeTypeOf('function') - handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) // The first load after spawn only advances generation. The second one sees // this PTY as belonging to a prior page load and kills it as orphaned. @@ -779,7 +810,7 @@ describe('registerPtyHandlers', () => { ) }) - it('clears PTY state even when kill reports the process is already gone', () => { + it('clears PTY state even when kill reports the process is already gone', async () => { const proc = { onData: vi.fn(() => makeDisposable()), onExit: vi.fn(() => makeDisposable()), @@ -787,16 +818,21 @@ describe('registerPtyHandlers', () => { resize: vi.fn(), kill: vi.fn(() => { throw new Error('already dead') - }) + }), + process: 'zsh', + pid: 12345 } spawnMock.mockReturnValue(proc) registerPtyHandlers(mainWindow as never) - const spawnResult = handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) as { id: string } + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } - handlers.get('pty:kill')!(null, { id: spawnResult.id }) + await handlers.get('pty:kill')!(null, { id: spawnResult.id }) - expect(handlers.get('pty:hasChildProcesses')!(null, { id: spawnResult.id })).toBe(false) + expect(await handlers.get('pty:hasChildProcesses')!(null, { id: spawnResult.id })).toBe(false) expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id) expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 50cdd249..8f049eae 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3,298 +3,94 @@ main-process module so spawn-time environment scoping, lifecycle cleanup, foreground-process inspection, and renderer IPC stay behind a single audited boundary. Splitting it by line count would scatter tightly coupled terminal process behavior across files without a cleaner ownership seam. */ -import { basename, win32 as pathWin32 } from 'path' -import { - existsSync, - accessSync, - statSync, - chmodSync, - mkdirSync, - writeFileSync, - constants as fsConstants -} from 'fs' -import { app, type BrowserWindow, ipcMain } from 'electron' -import * as pty from 'node-pty' +import { type BrowserWindow, ipcMain } from 'electron' +export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import { parseWslPath } from '../wsl' import { openCodeHookService } from '../opencode/hook-service' import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' +import { LocalPtyProvider } from '../providers/local-pty-provider' +import type { IPtyProvider } from '../providers/types' -let ptyCounter = 0 -const ptyProcesses = new Map() -/** Basename of the shell binary each PTY was spawned with (e.g. "zsh"). */ -const ptyShellName = new Map() -// Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction -// callbacks. If the PTY is killed without disposing these listeners, the -// stale callbacks survive into node::FreeEnvironment() where NAPI attempts -// to invoke/clean them up on a destroyed environment, triggering a SIGABRT -// via Napi::Error::ThrowAsJavaScriptException. Storing and calling the -// disposables before proc.kill() prevents the use-after-free crash. -const ptyDisposables = new Map void }[]>() +// ─── Provider Registry ────────────────────────────────────────────── +// Routes PTY operations by connectionId. null = local provider. +// SSH providers will be registered here in Phase 1. -// Track which "page load generation" each PTY belongs to. -// When the renderer reloads, we only kill PTYs from previous generations, -// not ones spawned during the current page load. This prevents a race -// condition where did-finish-load fires after PTYs have already been -// created by the new page, killing them and leaving blank terminals. -let loadGeneration = 0 -const ptyLoadGeneration = new Map() -let didEnsureSpawnHelperExecutable = false -let didEnsureShellReadyWrappers = false +const localProvider = new LocalPtyProvider() +const sshProviders = new Map() +// Why: PTY IDs are assigned at spawn time with a connectionId, but subsequent +// write/resize/kill calls only carry the PTY ID. This map lets us route +// post-spawn operations to the correct provider without the renderer needing +// to track connectionId per-PTY. +const ptyOwnership = new Map() -function quotePosixSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` +function getProvider(connectionId: string | null | undefined): IPtyProvider { + if (!connectionId) { + return localProvider + } + const provider = sshProviders.get(connectionId) + if (!provider) { + throw new Error(`No PTY provider for connection "${connectionId}"`) + } + return provider } -const STARTUP_COMMAND_READY_MAX_WAIT_MS = 1500 -const OSC_133_A = '\x1b]133;A' - -type ShellReadyScanState = { - matchPos: number - heldBytes: string +function getProviderForPty(ptyId: string): IPtyProvider { + const connectionId = ptyOwnership.get(ptyId) + if (connectionId === undefined) { + return localProvider + } + return getProvider(connectionId) } -function createShellReadyScanState(): ShellReadyScanState { - return { matchPos: 0, heldBytes: '' } +/** Register an SSH PTY provider for a connection. */ +export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { + sshProviders.set(connectionId, provider) } -function scanForShellReady( - state: ShellReadyScanState, - data: string -): { output: string; matched: boolean } { - let output = '' +/** Remove an SSH PTY provider when a connection is closed. */ +export function unregisterSshPtyProvider(connectionId: string): void { + sshProviders.delete(connectionId) +} - for (let i = 0; i < data.length; i += 1) { - const ch = data[i] as string - if (state.matchPos < OSC_133_A.length) { - if (ch === OSC_133_A[state.matchPos]) { - state.heldBytes += ch - state.matchPos += 1 - } else { - output += state.heldBytes - state.heldBytes = '' - state.matchPos = 0 - if (ch === OSC_133_A[0]) { - state.heldBytes = ch - state.matchPos = 1 - } else { - output += ch - } - } - } else if (ch === '\x07') { - const remaining = data.slice(i + 1) - state.heldBytes = '' - state.matchPos = 0 - return { output: output + remaining, matched: true } - } else { - state.heldBytes += ch +/** Get the SSH PTY provider for a connection (for dispose on cleanup). */ +export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined { + return sshProviders.get(connectionId) +} + +/** Get the local PTY provider (for direct access in tests/runtime). */ +export function getLocalPtyProvider(): LocalPtyProvider { + return localProvider +} + +/** Get all PTY IDs owned by a given connectionId (for reconnection reattach). */ +export function getPtyIdsForConnection(connectionId: string): string[] { + const ids: string[] = [] + for (const [ptyId, connId] of ptyOwnership) { + if (connId === connectionId) { + ids.push(ptyId) } } - - return { output, matched: false } + return ids } -function getShellReadyWrapperRoot(): string { - return `${app.getPath('userData')}/shell-ready` -} - -export function getBashShellReadyRcfileContent(): string { - return `# Orca bash shell-ready wrapper -[[ -f /etc/profile ]] && source /etc/profile -if [[ -f "$HOME/.bash_profile" ]]; then - source "$HOME/.bash_profile" -elif [[ -f "$HOME/.bash_login" ]]; then - source "$HOME/.bash_login" -elif [[ -f "$HOME/.profile" ]]; then - source "$HOME/.profile" -fi -# Why: preserve bash's normal login-shell contract. Many users already source -# ~/.bashrc from ~/.bash_profile; forcing ~/.bashrc again here would duplicate -# PATH edits, hooks, and prompt init in Orca startup-command shells. -# Why: append the marker through PROMPT_COMMAND so it fires after the login -# startup files have rebuilt the prompt, matching Superset's "shell ready" -# contract without re-running user rc files. -__orca_prompt_mark() { - printf "\\033]133;A\\007" -} -if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then - PROMPT_COMMAND=("\${PROMPT_COMMAND[@]}" "__orca_prompt_mark") -else - _orca_prev_prompt_command="\${PROMPT_COMMAND}" - if [[ -n "\${_orca_prev_prompt_command}" ]]; then - PROMPT_COMMAND="\${_orca_prev_prompt_command};__orca_prompt_mark" - else - PROMPT_COMMAND="__orca_prompt_mark" - fi -fi -` -} - -function ensureShellReadyWrappers(): void { - if (didEnsureShellReadyWrappers || process.platform === 'win32') { - return - } - didEnsureShellReadyWrappers = true - - const root = getShellReadyWrapperRoot() - const zshDir = `${root}/zsh` - const bashDir = `${root}/bash` - - const zshEnv = `# Orca zsh shell-ready wrapper -export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -export ZDOTDIR=${quotePosixSingle(zshDir)} -` - const zshProfile = `# Orca zsh shell-ready wrapper -_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" -[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile" -` - const zshRc = `# Orca zsh shell-ready wrapper -_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" -if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then - source "$_orca_home/.zshrc" -fi -` - const zshLogin = `# Orca zsh shell-ready wrapper -_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" -if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then - source "$_orca_home/.zlogin" -fi -# Why: emit OSC 133;A only after the user's startup hooks finish so Orca knows -# the prompt is actually ready for a long startup command paste. -__orca_prompt_mark() { - printf "\\033]133;A\\007" -} -precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) -` - const bashRc = getBashShellReadyRcfileContent() - - const files = [ - [`${zshDir}/.zshenv`, zshEnv], - [`${zshDir}/.zprofile`, zshProfile], - [`${zshDir}/.zshrc`, zshRc], - [`${zshDir}/.zlogin`, zshLogin], - [`${bashDir}/rcfile`, bashRc] - ] as const - - for (const [path, content] of files) { - const dir = path.slice(0, path.lastIndexOf('/')) - mkdirSync(dir, { recursive: true }) - writeFileSync(path, content, 'utf8') - chmodSync(path, 0o644) - } -} - -function getShellReadyLaunchConfig(shellPath: string): { - args: string[] | null - env: Record - supportsReadyMarker: boolean -} { - const shellName = basename(shellPath).toLowerCase() - - if (shellName === 'zsh') { - ensureShellReadyWrappers() - return { - args: ['-l'], - env: { - ORCA_ORIG_ZDOTDIR: process.env.ZDOTDIR || process.env.HOME || '', - ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh` - }, - supportsReadyMarker: true +/** + * Remove all PTY ownership entries for a given connectionId. + * Why: when an SSH connection is closed, the remote PTYs are gone but their + * ownership entries linger. Without cleanup, subsequent spawn calls could + * look up a stale provider for those PTY IDs, and the map grows unboundedly. + */ +export function clearPtyOwnershipForConnection(connectionId: string): void { + for (const [ptyId, connId] of ptyOwnership) { + if (connId === connectionId) { + ptyOwnership.delete(ptyId) } } - - if (shellName === 'bash') { - ensureShellReadyWrappers() - return { - args: ['--rcfile', `${getShellReadyWrapperRoot()}/bash/rcfile`], - env: {}, - supportsReadyMarker: true - } - } - - return { - args: null, - env: {}, - supportsReadyMarker: false - } } -function writeStartupCommandWhenShellReady( - readyPromise: Promise, - proc: pty.IPty, - startupCommand: string, - onExit: (cleanup: () => void) => void -): void { - let sent = false - const cleanup = (): void => { - sent = true - } +// ─── Provider-scoped PTY state cleanup ────────────────────────────── - const flush = (): void => { - if (sent) { - return - } - sent = true - // Why: run startup commands inside the same interactive shell Orca keeps - // open for the pane. Spawning `shell -c ; exec shell -l` would - // avoid the race, but it would also replace the session after the agent - // exits and break "stay in this terminal" workflows. - const payload = startupCommand.endsWith('\n') ? startupCommand : `${startupCommand}\n` - // Why: startup commands are usually long, quoted agent launches. Writing - // them in one PTY call after the shell-ready barrier avoids the incremental - // paste behavior that still dropped characters in practice. - proc.write(payload) - } - - readyPromise.then(flush) - onExit(cleanup) -} - -function disposePtyListeners(id: string): void { - const disposables = ptyDisposables.get(id) - if (disposables) { - for (const d of disposables) { - d.dispose() - } - ptyDisposables.delete(id) - } -} - -function clearPtyState(id: string): void { - disposePtyListeners(id) - clearPtyRegistryState(id) -} - -function clearPtyRegistryState(id: string): void { - ptyProcesses.delete(id) - ptyShellName.delete(id) - ptyLoadGeneration.delete(id) -} - -function killPtyProcess(id: string, proc: pty.IPty): boolean { - // Why: node-pty's listener disposables must be torn down before proc.kill() - // on every explicit teardown path, not just app quit. Some kills happen - // during reload/manual-close flows where waiting for later state cleanup is - // too late to stop the stale NAPI callbacks from surviving into shutdown. - disposePtyListeners(id) - let killed = true - try { - proc.kill() - } catch { - killed = false - } - // Why: once an explicit kill path decides this PTY is done, we must clear - // the bookkeeping maps even if node-pty reports the process was already - // gone. Leaving the stale registry entry behind makes later lookups think - // the PTY is still live even though runtime teardown already ran. - clearPtyRegistryState(id) - clearProviderPtyState(id) - return killed -} - -function clearProviderPtyState(id: string): void { +export function clearProviderPtyState(id: string): void { // Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the // node-pty process table. Centralizing provider cleanup avoids drift where a // new teardown path forgets to remove one provider's overlay/hook state. @@ -302,64 +98,19 @@ function clearProviderPtyState(id: string): void { piTitlebarExtensionService.clearPty(id) } -function getShellValidationError(shellPath: string): string | null { - if (!existsSync(shellPath)) { - return ( - `Shell "${shellPath}" does not exist. ` + - `Set a valid SHELL environment variable or install zsh/bash.` - ) - } - try { - accessSync(shellPath, fsConstants.X_OK) - } catch { - return `Shell "${shellPath}" is not executable. Check file permissions.` - } - return null +export function deletePtyOwnership(id: string): void { + ptyOwnership.delete(id) } -function ensureNodePtySpawnHelperExecutable(): void { - if (didEnsureSpawnHelperExecutable || process.platform === 'win32') { - return - } - didEnsureSpawnHelperExecutable = true +// Why: localProvider.onData/onExit return unsubscribe functions. Without +// storing and calling these on re-registration, macOS app re-activation +// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking +// duplicate listeners that forward every event twice. +let localDataUnsub: (() => void) | null = null +let localExitUnsub: (() => void) | null = null +let didFinishLoadHandler: (() => void) | null = null - try { - const unixTerminalPath = require.resolve('node-pty/lib/unixTerminal.js') - const packageRoot = - basename(unixTerminalPath) === 'unixTerminal.js' - ? unixTerminalPath.replace(/[/\\]lib[/\\]unixTerminal\.js$/, '') - : unixTerminalPath - const candidates = [ - `${packageRoot}/build/Release/spawn-helper`, - `${packageRoot}/build/Debug/spawn-helper`, - `${packageRoot}/prebuilds/${process.platform}-${process.arch}/spawn-helper` - ].map((candidate) => - candidate - .replace('app.asar/', 'app.asar.unpacked/') - .replace('node_modules.asar/', 'node_modules.asar.unpacked/') - ) - - for (const candidate of candidates) { - if (!existsSync(candidate)) { - continue - } - const mode = statSync(candidate).mode - if ((mode & 0o111) !== 0) { - return - } - // Why: node-pty's Unix backend launches this helper before the requested - // shell binary. Some package-manager/install paths strip the execute bit - // from the prebuilt helper, which makes every PTY spawn fail with the - // misleading "posix_spawnp failed" shell error even when /bin/zsh exists. - chmodSync(candidate, mode | 0o755) - return - } - } catch (error) { - console.warn( - `[pty] Failed to ensure node-pty spawn-helper is executable: ${error instanceof Error ? error.message : String(error)}` - ) - } -} +// ─── IPC Registration ─────────────────────────────────────────────── export function registerPtyHandlers( mainWindow: BrowserWindow, @@ -375,180 +126,25 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:getForegroundProcess') ipcMain.removeAllListeners('pty:write') - // Kill orphaned PTY processes from previous page loads when the renderer reloads. - // PTYs tagged with the current loadGeneration were spawned during THIS page load - // and must be preserved — only kill PTYs from earlier generations. - mainWindow.webContents.on('did-finish-load', () => { - for (const [id, proc] of ptyProcesses) { - const gen = ptyLoadGeneration.get(id) ?? -1 - if (gen < loadGeneration) { - killPtyProcess(id, proc) - // Why: notify runtime so the agent detector can close out any live - // agent sessions. Without this, killed PTYs would remain in the - // detector's liveAgents map and accumulate inflated durations. - runtime?.onPtyExit(id, -1) - } - } - // Advance generation for the next page load - loadGeneration++ - }) - - runtime?.setPtyController({ - write: (ptyId, data) => { - const proc = ptyProcesses.get(ptyId) - if (!proc) { - return false - } - proc.write(data) - return true - }, - kill: (ptyId) => { - const proc = ptyProcesses.get(ptyId) - if (!proc) { - return false - } - if (!killPtyProcess(ptyId, proc)) { - return false - } - runtime?.onPtyExit(ptyId, -1) - return true - } - }) - - ipcMain.handle( - 'pty:spawn', - ( - _event, - args: { - cols: number - rows: number - cwd?: string - env?: Record - command?: string - } - ) => { - const id = String(++ptyCounter) - - const defaultCwd = - process.platform === 'win32' - ? process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\' - : process.env.HOME || '/' - - const cwd = args.cwd || defaultCwd - - // Why: when the working directory is inside a WSL filesystem, spawn a - // WSL shell (wsl.exe) instead of a native Windows shell. This gives the - // user a Linux environment with access to their WSL-installed tools - // (git, node, etc.) rather than a PowerShell with no WSL toolchain. - const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null - - let shellPath: string - let shellArgs: string[] - let effectiveCwd: string - let validationCwd: string - let shellReadyLaunch: { - args: string[] | null - env: Record - supportsReadyMarker: boolean - } | null = null - if (wslInfo) { - // Why: use `bash -c "cd ... && exec bash -l"` instead of `--cd` because - // wsl.exe's --cd flag fails with ERROR_PATH_NOT_FOUND in some Node - // spawn configurations. The exec replaces the outer bash with a login - // shell so the user gets their normal shell environment. - const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''") - shellPath = 'wsl.exe' - shellArgs = ['-d', wslInfo.distro, '--', 'bash', '-c', `cd '${escapedCwd}' && exec bash -l`] - // Why: set cwd to a valid Windows directory so node-pty's native - // spawn doesn't fail on the UNC path. - effectiveCwd = process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\' - // Why: still validate the requested WSL UNC path, not the fallback - // Windows cwd. Otherwise a deleted/mistyped WSL worktree silently - // spawns a shell in the home directory and hides the real error. - validationCwd = cwd - } else if (process.platform === 'win32') { - shellPath = process.env.COMSPEC || 'powershell.exe' - // Why: use path.win32.basename so backslash-separated Windows paths - // are parsed correctly even when tests mock process.platform on Linux CI. - const shellBasename = pathWin32.basename(shellPath).toLowerCase() - // Why: On CJK Windows (Chinese, Japanese, Korean), the console code page - // defaults to the system ANSI code page (e.g. 936/GBK for Chinese). - // ConPTY encodes its output pipe using this code page, but node-pty - // always decodes as UTF-8. Without switching to code page 65001 (UTF-8), - // multi-byte CJK characters are garbled because the GBK/Shift-JIS/EUC-KR - // byte sequences are misinterpreted as UTF-8. This is especially visible - // with split-screen terminals where multiple ConPTY instances amplify the - // issue. Setting the code page at shell startup ensures all subsequent - // output — including from child processes — uses UTF-8. - if (shellBasename === 'cmd.exe') { - shellArgs = ['/K', 'chcp 65001 > nul'] - } else if (shellBasename === 'powershell.exe' || shellBasename === 'pwsh.exe') { - // Why: `-NoExit -Command` alone skips the user's $PROFILE, breaking - // custom prompts (oh-my-posh, starship), aliases, and PSReadLine - // configuration. Dot-sourcing $PROFILE first restores the normal - // startup experience. The try/catch ensures a broken profile (e.g. - // terminating errors from strict-mode violations or failing module - // imports) cannot prevent the encoding commands from executing — - // otherwise the CJK fix would silently fail for those users. - shellArgs = [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ] - } else { - shellArgs = [] - } - effectiveCwd = cwd - validationCwd = cwd - } else { - // Why: startup commands can pass env overrides for the PTY. Prefer an - // explicit SHELL override when present, but still validate/fallback it - // exactly like the inherited process shell so stale config can't brick - // terminal creation. - shellPath = args.env?.SHELL || process.env.SHELL || '/bin/zsh' - shellReadyLaunch = args.command ? getShellReadyLaunchConfig(shellPath) : null - shellArgs = shellReadyLaunch?.args ?? ['-l'] - effectiveCwd = cwd - validationCwd = cwd - } - - ensureNodePtySpawnHelperExecutable() - - if (!existsSync(validationCwd)) { - throw new Error( - `Working directory "${validationCwd}" does not exist. ` + - `It may have been deleted or is on an unmounted volume.` - ) - } - if (!statSync(validationCwd).isDirectory()) { - throw new Error(`Working directory "${validationCwd}" is not a directory.`) - } - + // Configure the local provider with app-specific hooks + localProvider.configure({ + buildSpawnEnv: (id, baseEnv) => { const selectedCodexHomePath = getSelectedCodexHomePath?.() ?? null - const spawnEnv = { - ...process.env, - ...args.env, - ...shellReadyLaunch?.env, - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'Orca', - FORCE_HYPERLINK: '1' - } as Record const openCodeHookEnv = openCodeHookService.buildPtyEnv(id) - if (spawnEnv.OPENCODE_CONFIG_DIR) { + if (baseEnv.OPENCODE_CONFIG_DIR) { // Why: OPENCODE_CONFIG_DIR is a singular extra config root. Replacing a // user-provided directory would silently hide their custom OpenCode // config, so preserve it and fall back to title-only detection there. delete openCodeHookEnv.OPENCODE_CONFIG_DIR } - Object.assign(spawnEnv, openCodeHookEnv) + Object.assign(baseEnv, openCodeHookEnv) // Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a // PTY-scoped overlay from the caller's chosen root so Pi sessions keep // their user state without sharing a mutable overlay across terminals. Object.assign( - spawnEnv, - piTitlebarExtensionService.buildPtyEnv(id, spawnEnv.PI_CODING_AGENT_DIR) + baseEnv, + piTitlebarExtensionService.buildPtyEnv(id, baseEnv.PI_CODING_AGENT_DIR) ) // Why: the selected Codex account should affect Codex launched inside @@ -557,258 +153,140 @@ export function registerPtyHandlers( // stays scoped to Orca terminals instead of mutating the app process or // the user's external shells. if (selectedCodexHomePath) { - spawnEnv.CODEX_HOME = selectedCodexHomePath - } - // Why: When Electron is launched from Finder (not a terminal), the process - // does not inherit the user's shell locale settings. Without an explicit - // UTF-8 locale, multi-byte characters (e.g. em dashes U+2014) are - // misinterpreted by the PTY and rendered as garbled sequences like "�~@~T". - // We default LANG to en_US.UTF-8 but let the inherited or caller-provided - // env override it so user locale preferences are respected. - spawnEnv.LANG ??= 'en_US.UTF-8' - - // Why: On Windows, LANG alone does not control the console code page. - // Programs like Python and Node.js check their own encoding env vars - // independently. PYTHONUTF8=1 makes Python use UTF-8 for stdio regardless - // of the Windows console code page, preventing garbled CJK output from - // Python scripts run inside the terminal. - if (process.platform === 'win32') { - spawnEnv.PYTHONUTF8 ??= '1' + baseEnv.CODEX_HOME = selectedCodexHomePath } - let ptyProcess: pty.IPty | undefined - let primaryError: string | null = null - if (process.platform !== 'win32') { - primaryError = getShellValidationError(shellPath) - } + return baseEnv + }, + onSpawned: (id) => runtime?.onPtySpawned(id), + onExit: (id, code) => { + clearProviderPtyState(id) + ptyOwnership.delete(id) + runtime?.onPtyExit(id, code) + }, + onData: (id, data, timestamp) => runtime?.onPtyData(id, data, timestamp) + }) - if (!primaryError) { - try { - ptyProcess = pty.spawn(shellPath, shellArgs, { - name: 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd: effectiveCwd, - env: spawnEnv - }) - } catch (err) { - // Why: node-pty.spawn can throw if posix_spawnp fails for reasons - // not caught by the validation above (e.g. architecture mismatch - // of the native addon, PTY allocation failure, or resource limits). - primaryError = err instanceof Error ? err.message : String(err) - } - } + // Wire up provider events → renderer IPC + localDataUnsub?.() + localExitUnsub?.() + localDataUnsub = localProvider.onData((payload) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:data', payload) + } + }) + localExitUnsub = localProvider.onExit((payload) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:exit', payload) + } + }) - if (!ptyProcess && process.platform !== 'win32') { - // Why: a stale login shell path (common after Homebrew/bash changes) - // should not brick Orca terminals. Fall back to system shells so the - // user still gets a working terminal while the bad SHELL config remains. - const configuredShellPath = shellPath - const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter( - (s) => s !== configuredShellPath - ) - for (const fallback of fallbackShells) { - if (getShellValidationError(fallback)) { - continue - } - try { - // Why: set SHELL to the fallback *before* spawning so the child - // process inherits the correct value. Leaving the stale original - // SHELL in the env would confuse shell startup logic and any - // subprocesses that inspect $SHELL. - shellReadyLaunch = args.command ? getShellReadyLaunchConfig(fallback) : null - spawnEnv.SHELL = fallback - Object.assign(spawnEnv, shellReadyLaunch?.env ?? {}) - ptyProcess = pty.spawn(fallback, shellReadyLaunch?.args ?? ['-l'], { - name: 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd: effectiveCwd, - env: spawnEnv - }) - console.warn( - `[pty] Primary shell "${configuredShellPath}" failed (${primaryError ?? 'unknown error'}), fell back to "${fallback}"` - ) - shellPath = fallback - break - } catch { - // Fallback also failed — try next. - } - } - } + // Kill orphaned PTY processes from previous page loads when the renderer reloads. + // Why: store the handler reference so we can remove it on re-registration, + // preventing duplicate handlers after macOS app re-activation. + if (didFinishLoadHandler) { + mainWindow.webContents.removeListener('did-finish-load', didFinishLoadHandler) + } + didFinishLoadHandler = () => { + const killed = localProvider.killOrphanedPtys(localProvider.advanceGeneration() - 1) + for (const { id } of killed) { + clearProviderPtyState(id) + ptyOwnership.delete(id) + runtime?.onPtyExit(id, -1) + } + } + mainWindow.webContents.on('did-finish-load', didFinishLoadHandler) - if (!ptyProcess) { - const diag = [ - `shell: ${shellPath}`, - `cwd: ${effectiveCwd}`, - `arch: ${process.arch}`, - `platform: ${process.platform} ${process.getSystemVersion?.() ?? ''}` - ].join(', ') - throw new Error( - `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ` + - `If this persists, please file an issue.` - ) + // Why: the runtime controller must route through getProviderForPty() so that + // CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs. + // Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs. + runtime?.setPtyController({ + write: (ptyId, data) => { + const provider = getProviderForPty(ptyId) + try { + provider.write(ptyId, data) + return true + } catch { + return false } + }, + kill: (ptyId) => { + const provider = getProviderForPty(ptyId) + // Why: shutdown() is async but the PtyController interface is sync. + // Swallowing the rejection prevents an unhandled promise rejection crash + // if the remote SSH session is already gone. + void provider.shutdown(ptyId, false).catch(() => {}) + clearProviderPtyState(ptyId) + runtime?.onPtyExit(ptyId, -1) + return true + } + }) - if (process.platform !== 'win32') { - // Why: after a successful fallback, update spawnEnv.SHELL to match what - // was actually launched. The value was already set inside the fallback loop - // before spawn, but we also need shellPath to reflect the fallback for the - // ptyShellName map below. (Primary-path spawns already have the correct - // SHELL from process.env / args.env.) - spawnEnv.SHELL = shellPath - } - const proc = ptyProcess - ptyProcesses.set(id, proc) - ptyShellName.set(id, basename(shellPath)) - ptyLoadGeneration.set(id, loadGeneration) - runtime?.onPtySpawned(id) + // ─── IPC Handlers (thin dispatch layer) ───────────────────────── - let resolveShellReady: (() => void) | null = null - let shellReadyTimeout: ReturnType | null = null - const shellReadyScanState = shellReadyLaunch?.supportsReadyMarker - ? createShellReadyScanState() - : null - const shellReadyPromise = args.command - ? new Promise((resolve) => { - resolveShellReady = resolve - }) - : Promise.resolve() - const finishShellReady = (): void => { - if (!resolveShellReady) { - return - } - if (shellReadyTimeout) { - clearTimeout(shellReadyTimeout) - shellReadyTimeout = null - } - const resolve = resolveShellReady - resolveShellReady = null - resolve() + ipcMain.handle( + 'pty:spawn', + async ( + _event, + args: { + cols: number + rows: number + cwd?: string + env?: Record + command?: string + connectionId?: string | null } - if (args.command) { - if (shellReadyLaunch?.supportsReadyMarker) { - shellReadyTimeout = setTimeout(() => { - finishShellReady() - }, STARTUP_COMMAND_READY_MAX_WAIT_MS) - } else { - finishShellReady() - } - } - let startupCommandCleanup: (() => void) | null = null - - const onDataDisposable = proc.onData((rawData) => { - let data = rawData - if (shellReadyScanState && resolveShellReady) { - const scanned = scanForShellReady(shellReadyScanState, rawData) - data = scanned.output - if (scanned.matched) { - finishShellReady() - } - } - if (data.length === 0) { - return - } - runtime?.onPtyData(id, data, Date.now()) - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:data', { id, data }) - } + ) => { + const provider = getProvider(args.connectionId) + const result = await provider.spawn({ + cols: args.cols, + rows: args.rows, + cwd: args.cwd, + env: args.env, + command: args.command }) - - const onExitDisposable = proc.onExit(({ exitCode }) => { - if (shellReadyTimeout) { - clearTimeout(shellReadyTimeout) - shellReadyTimeout = null - } - startupCommandCleanup?.() - clearPtyState(id) - clearProviderPtyState(id) - runtime?.onPtyExit(id, exitCode) - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:exit', { id, code: exitCode }) - } - }) - - ptyDisposables.set(id, [onDataDisposable, onExitDisposable]) - - if (args.command) { - writeStartupCommandWhenShellReady(shellReadyPromise, proc, args.command, (cleanup) => { - startupCommandCleanup = cleanup - }) - } - - return { id } + ptyOwnership.set(result.id, args.connectionId ?? null) + return result } ) ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => { - const proc = ptyProcesses.get(args.id) - if (proc) { - proc.write(args.data) - } + getProviderForPty(args.id).write(args.id, args.data) }) ipcMain.handle('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { - const proc = ptyProcesses.get(args.id) - if (proc) { - proc.resize(args.cols, args.rows) - } + getProviderForPty(args.id).resize(args.id, args.cols, args.rows) }) - ipcMain.handle('pty:kill', (_event, args: { id: string }) => { - const proc = ptyProcesses.get(args.id) - if (proc) { - killPtyProcess(args.id, proc) - runtime?.onPtyExit(args.id, -1) - } - }) - - // Check whether the terminal's foreground process differs from its shell - // (e.g. the user is running `node server.js`). Uses node-pty's native - // .process getter which reads the OS process table directly — no external - // tools like pgrep required. - ipcMain.handle('pty:hasChildProcesses', (_event, args: { id: string }): boolean => { - const proc = ptyProcesses.get(args.id) - if (!proc) { - return false - } + ipcMain.handle('pty:kill', async (_event, args: { id: string }) => { + // Why: try/finally ensures ptyOwnership is cleaned up even if shutdown + // throws (e.g. SSH connection already gone). Without this, the stale + // entry routes future lookups to a dead provider. try { - const foreground = proc.process - const shell = ptyShellName.get(args.id) - // If we can't determine the shell name, err on the side of caution. - if (!shell) { - return true - } - return foreground !== shell - } catch { - // .process can throw if the PTY fd is already closed. - return false + await getProviderForPty(args.id).shutdown(args.id, true) + } finally { + ptyOwnership.delete(args.id) } }) - ipcMain.handle('pty:getForegroundProcess', (_event, args: { id: string }): string | null => { - const proc = ptyProcesses.get(args.id) - if (!proc) { - return null + ipcMain.handle( + 'pty:hasChildProcesses', + async (_event, args: { id: string }): Promise => { + return getProviderForPty(args.id).hasChildProcesses(args.id) } - try { - // Why: live Codex-session actions must key off the PTY foreground process, - // not the tab title. Agent CLIs do not reliably emit stable OSC titles, - // so title-based detection misses real Codex sessions that still need a - // restart after account switching. - return proc.process || null - } catch { - // .process can throw if the PTY fd is already closed. - return null + ) + + ipcMain.handle( + 'pty:getForegroundProcess', + async (_event, args: { id: string }): Promise => { + return getProviderForPty(args.id).getForegroundProcess(args.id) } - }) + ) } /** * Kill all PTY processes. Call on app quit. */ export function killAllPty(): void { - for (const [id, proc] of ptyProcesses) { - killPtyProcess(id, proc) - } + localProvider.killAll() } diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts new file mode 100644 index 00000000..1e7d19c4 --- /dev/null +++ b/src/main/ipc/repos-remote.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { handleMock, mockStore, mockGitProvider } = vi.hoisted(() => ({ + handleMock: vi.fn(), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeRepo: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn() + }, + mockGitProvider: { + isGitRepo: vi.fn().mockReturnValue(true), + isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }) + } +})) + +vi.mock('electron', () => ({ + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { + handle: handleMock, + removeHandler: vi.fn() + } +})) + +vi.mock('../git/repo', () => ({ + isGitRepo: vi.fn().mockReturnValue(true), + getGitUsername: vi.fn().mockReturnValue(''), + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), + searchBaseRefs: vi.fn().mockResolvedValue([]) +})) + +vi.mock('./filesystem-auth', () => ({ + rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: vi.fn().mockImplementation((id: string) => { + if (id === 'conn-1') { + return mockGitProvider + } + return undefined + }) +})) + +import { registerRepoHandlers } from './repos' + +describe('repos:addRemote', () => { + const handlers = new Map unknown>() + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() } + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + mockStore.getRepos.mockReset().mockReturnValue([]) + mockStore.addRepo.mockReset() + mockWindow.webContents.send.mockReset() + + registerRepoHandlers(mockWindow as never, mockStore as never) + }) + + it('registers the repos:addRemote handler', () => { + expect(handlers.has('repos:addRemote')).toBe(true) + }) + + it('creates a remote repo with connectionId', async () => { + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/project' + }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/home/user/project', + connectionId: 'conn-1', + kind: 'git', + displayName: 'project' + }) + ) + expect(result).toHaveProperty('id') + expect(result).toHaveProperty('connectionId', 'conn-1') + }) + + it('uses custom displayName when provided', async () => { + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/project', + displayName: 'My Server Repo' + }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + displayName: 'My Server Repo' + }) + ) + expect(result).toHaveProperty('displayName', 'My Server Repo') + }) + + it('returns existing repo if same connectionId and path already added', async () => { + const existing = { + id: 'existing-id', + path: '/home/user/project', + connectionId: 'conn-1', + displayName: 'project', + badgeColor: '#fff', + addedAt: 1000, + kind: 'git' + } + mockStore.getRepos.mockReturnValue([existing]) + + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/project' + }) + + expect(result).toEqual(existing) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('throws when SSH connection is not found', async () => { + await expect( + handlers.get('repos:addRemote')!(null, { + connectionId: 'unknown-conn', + remotePath: '/home/user/project' + }) + ).rejects.toThrow('SSH connection "unknown-conn" not found') + }) + + it('throws when remote path is not a git repo', async () => { + mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ isRepo: false, rootPath: null }) + + await expect( + handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/documents' + }) + ).rejects.toThrow('Not a valid git repository') + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('adds as folder when kind is explicitly set', async () => { + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/documents', + kind: 'folder' + }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'folder', + path: '/home/user/documents' + }) + ) + expect(result).toHaveProperty('kind', 'folder') + }) + + it('uses rootPath from git detection when available', async () => { + mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ + isRepo: true, + rootPath: '/home/user/project' + }) + + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/project/src' + }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'git', + path: '/home/user/project' + }) + ) + expect(result).toHaveProperty('path', '/home/user/project') + }) + + it('notifies renderer when remote repo is added', async () => { + await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/project' + }) + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed') + }) +}) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 4b908f1c..0b4fb1d2 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: repo IPC is intentionally centralized so SSH +routing, clone lifecycle, and store persistence stay behind a single audited +boundary. Splitting by line count would scatter tightly coupled repo behavior. */ import type { BrowserWindow } from 'electron' import { dialog, ipcMain } from 'electron' import { randomUUID } from 'crypto' @@ -17,6 +20,8 @@ import { getBaseRefDefault, searchBaseRefs } from '../git/repo' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { getActiveMultiplexer } from './ssh' // Why: module-scoped so the abort handle survives window re-creation on macOS. // registerRepoHandlers is called again when a new BrowserWindow is created, @@ -38,6 +43,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:getGitUsername') ipcMain.removeHandler('repos:getBaseRefDefault') ipcMain.removeHandler('repos:searchBaseRefs') + ipcMain.removeHandler('repos:addRemote') ipcMain.handle('repos:list', () => { return store.getRepos() @@ -70,6 +76,83 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return repo }) + ipcMain.handle( + 'repos:addRemote', + async ( + _event, + args: { + connectionId: string + remotePath: string + displayName?: string + kind?: 'git' | 'folder' + } + ): Promise => { + const gitProvider = getSshGitProvider(args.connectionId) + if (!gitProvider) { + throw new Error(`SSH connection "${args.connectionId}" not found or not connected`) + } + + const existing = store + .getRepos() + .find((r) => r.connectionId === args.connectionId && r.path === args.remotePath) + if (existing) { + return existing + } + + const pathSegments = args.remotePath.replace(/\/+$/, '').split('/') + const folderName = pathSegments.at(-1) || args.remotePath + + let repoKind: 'git' | 'folder' = args.kind ?? 'git' + let resolvedPath = args.remotePath + + if (args.kind !== 'folder') { + // Why: when kind is not explicitly 'folder', verify the remote path is + // a git repo. Throw on failure so the renderer can show the "Open as + // Folder" confirmation dialog — matching the local add-repo behavior + // where non-git directories require explicit user consent. + try { + const check = await gitProvider.isGitRepoAsync(args.remotePath) + if (check.isRepo) { + repoKind = 'git' + if (check.rootPath) { + resolvedPath = check.rootPath + } + } else { + throw new Error(`Not a valid git repository: ${args.remotePath}`) + } + } catch (err) { + if (err instanceof Error && err.message.includes('Not a valid git repository')) { + throw err + } + throw new Error(`Not a valid git repository: ${args.remotePath}`) + } + } + + const repo: Repo = { + id: randomUUID(), + path: resolvedPath, + displayName: args.displayName || folderName, + badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + addedAt: Date.now(), + kind: repoKind, + connectionId: args.connectionId + } + + store.addRepo(repo) + notifyReposChanged(mainWindow) + + // Why: register the workspace root with the relay so mutating FS operations + // are scoped to this repo's path. Without this, the relay's path ACL would + // reject writes to the workspace after the first root is registered. + const mux = getActiveMultiplexer(args.connectionId) + if (mux) { + mux.notify('session.registerRoot', { rootPath: resolvedPath }) + } + + return repo + } + ) + ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { store.removeRepo(args.repoId) await rebuildAuthorizedRootsCache(store) @@ -239,11 +322,25 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } ) - ipcMain.handle('repos:getGitUsername', (_event, args: { repoId: string }) => { + ipcMain.handle('repos:getGitUsername', async (_event, args: { repoId: string }) => { const repo = store.getRepo(args.repoId) if (!repo || isFolderRepo(repo)) { return '' } + // Why: remote repos have their git config on the remote host, so we + // must route through the relay's git.exec to read user.name. + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + return '' + } + try { + const result = await provider.exec(['config', 'user.name'], repo.path) + return result.stdout.trim() + } catch { + return '' + } + } return getGitUsername(repo.path) }) @@ -252,6 +349,27 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if (!repo || isFolderRepo(repo)) { return 'origin/main' } + // Why: remote repos need the relay to resolve symbolic-ref on the + // remote host where the git data lives. + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + return 'origin/main' + } + try { + const result = await provider.exec( + ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + repo.path + ) + const ref = result.stdout.trim() + if (ref) { + return ref.replace(/^refs\/remotes\//, '') + } + } catch { + // Fall through to default + } + return 'origin/main' + } return getBaseRefDefault(repo.path) }) @@ -262,7 +380,34 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if (!repo || isFolderRepo(repo)) { return [] } - return searchBaseRefs(repo.path, args.query, args.limit ?? 25) + const limit = args.limit ?? 25 + // Why: remote repos need the relay to list branches on the remote host. + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + return [] + } + try { + const result = await provider.exec( + [ + 'for-each-ref', + '--format=%(refname:short)', + '--sort=-committerdate', + `refs/remotes/origin/*${args.query}*`, + `refs/heads/*${args.query}*` + ], + repo.path + ) + return result.stdout + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + .slice(0, limit) + } catch { + return [] + } + } + return searchBaseRefs(repo.path, args.query, limit) } ) } diff --git a/src/main/ipc/ssh-auth-helpers.ts b/src/main/ipc/ssh-auth-helpers.ts new file mode 100644 index 00000000..d9ce9fef --- /dev/null +++ b/src/main/ipc/ssh-auth-helpers.ts @@ -0,0 +1,68 @@ +import { randomUUID } from 'crypto' +import { ipcMain, type BrowserWindow } from 'electron' +import type { SshConnectionCallbacks } from '../ssh/ssh-connection' + +// Why: all three SSH auth callbacks (host-key-verify, auth-challenge, password) +// share the same IPC round-trip pattern: send a prompt event to the renderer, +// wait for a single response on a unique channel, clean up on timeout/close. +// Extracting the pattern into a generic helper avoids triplicating the cleanup +// logic and keeps ssh.ts under the max-lines threshold. +function promptRenderer( + win: BrowserWindow, + sendChannel: string, + sendPayload: Record, + fallback: T +): Promise { + return new Promise((resolve) => { + const responseChannel = `${sendChannel}-response-${randomUUID()}` + const onClosed = () => { + cleanup() + resolve(fallback) + } + const cleanup = () => { + ipcMain.removeAllListeners(responseChannel) + clearTimeout(timer) + win.removeListener('closed', onClosed) + } + const timer = setTimeout(() => { + cleanup() + resolve(fallback) + }, 120_000) + win.webContents.send(sendChannel, { ...sendPayload, responseChannel }) + ipcMain.once(responseChannel, (_event, value: T) => { + cleanup() + resolve(value) + }) + win.once('closed', onClosed) + }) +} + +export function buildSshAuthCallbacks( + getMainWindow: () => BrowserWindow | null +): Pick { + return { + onHostKeyVerify: async (req) => { + const win = getMainWindow() + if (!win || win.isDestroyed()) { + return false + } + return promptRenderer(win, 'ssh:host-key-verify', req, false) + }, + + onAuthChallenge: async (req) => { + const win = getMainWindow() + if (!win || win.isDestroyed()) { + return [] + } + return promptRenderer(win, 'ssh:auth-challenge', req, []) + }, + + onPasswordPrompt: async (targetId: string) => { + const win = getMainWindow() + if (!win || win.isDestroyed()) { + return null + } + return promptRenderer(win, 'ssh:password-prompt', { targetId }, null) + } + } +} diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts new file mode 100644 index 00000000..080fdb11 --- /dev/null +++ b/src/main/ipc/ssh-browse.ts @@ -0,0 +1,108 @@ +import { ipcMain } from 'electron' +import type { SshConnectionManager } from '../ssh/ssh-connection' + +export type RemoteDirEntry = { + name: string + isDirectory: boolean +} + +// Why: the relay's fs.readDir enforces workspace root ACLs, which aren't +// registered until a repo is added. This handler uses a raw SSH exec channel +// to list directories, allowing the user to browse the remote filesystem +// during the "add remote repo" flow before any roots exist. +export function registerSshBrowseHandler( + getConnectionManager: () => SshConnectionManager | null +): void { + ipcMain.removeHandler('ssh:browseDir') + + ipcMain.handle( + 'ssh:browseDir', + async ( + _event, + args: { targetId: string; dirPath: string } + ): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> => { + const mgr = getConnectionManager() + if (!mgr) { + throw new Error('SSH connection manager not initialized') + } + const conn = mgr.getConnection(args.targetId) + if (!conn) { + throw new Error(`SSH connection "${args.targetId}" not found`) + } + + // Why: using printf with a delimiter instead of ls avoids issues with + // filenames containing spaces or special characters. The -1 flag outputs + // one entry per line. The -p flag appends / to directories. + // We resolve ~ and get the absolute path via `cd && pwd`. + const command = `cd ${shellEscape(args.dirPath)} && pwd && ls -1ap` + const channel = await conn.exec(command) + + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + + channel.on('data', (data: Buffer) => { + stdout += data.toString() + }) + channel.stderr.on('data', (data: Buffer) => { + stderr += data.toString() + }) + channel.on('close', () => { + if (stderr.trim() && !stdout.trim()) { + reject(new Error(stderr.trim())) + return + } + + const lines = stdout.trim().split('\n') + if (lines.length === 0) { + reject(new Error('Empty response from remote')) + return + } + + const resolvedPath = lines[0] + const entries: RemoteDirEntry[] = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (!line || line === './' || line === '../') { + continue + } + if (line.endsWith('/')) { + entries.push({ name: line.slice(0, -1), isDirectory: true }) + } else { + entries.push({ name: line, isDirectory: false }) + } + } + + // Sort: directories first, then alphabetical + entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + resolve({ entries, resolvedPath }) + }) + }) + } + ) +} + +// Why: prevent shell injection in the directory path. Single-quote wrapping +// with escaped internal single quotes is the safest approach for sh/bash. +// Tilde must be expanded by the shell, so paths starting with ~ use $HOME +// substitution instead of literal quoting (single quotes suppress expansion). +function shellEscape(s: string): string { + if (s === '~') { + return '"$HOME"' + } + if (s.startsWith('~/')) { + return `"$HOME"/${shellEscapeRaw(s.slice(2))}` + } + return shellEscapeRaw(s) +} + +function shellEscapeRaw(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'` +} diff --git a/src/main/ipc/ssh-relay-helpers.ts b/src/main/ipc/ssh-relay-helpers.ts new file mode 100644 index 00000000..c15a56d9 --- /dev/null +++ b/src/main/ipc/ssh-relay-helpers.ts @@ -0,0 +1,187 @@ +// Why: extracted from ssh.ts to keep the main IPC module under the max-lines +// threshold. These helpers manage relay lifecycle (cleanup, event wiring, +// reconnection) and are called from both initial connect and reconnection paths. + +import type { BrowserWindow } from 'electron' +import { deployAndLaunchRelay } from '../ssh/ssh-relay-deploy' +import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { SshPtyProvider } from '../providers/ssh-pty-provider' +import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' +import { SshGitProvider } from '../providers/ssh-git-provider' +import { + registerSshPtyProvider, + unregisterSshPtyProvider, + getSshPtyProvider, + getPtyIdsForConnection, + clearPtyOwnershipForConnection, + clearProviderPtyState, + deletePtyOwnership +} from './pty' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider, + getSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import type { SshPortForwardManager } from '../ssh/ssh-port-forward' +import type { SshConnectionManager } from '../ssh/ssh-connection' + +export function cleanupConnection( + targetId: string, + activeMultiplexers: Map, + initializedConnections: Set, + portForwardManager: SshPortForwardManager | null +): void { + portForwardManager?.removeAllForwards(targetId) + const mux = activeMultiplexers.get(targetId) + if (mux) { + mux.dispose() + activeMultiplexers.delete(targetId) + } + // Why: clear PTY ownership entries before unregistering the provider so + // stale ownership entries don't route future lookups to a dead provider. + clearPtyOwnershipForConnection(targetId) + + // Why: dispose notification subscriptions before unregistering so the + // multiplexer's handler list doesn't retain stale callbacks that fire + // into a torn-down provider after disconnect. + const ptyProvider = getSshPtyProvider(targetId) + if (ptyProvider && 'dispose' in ptyProvider) { + ;(ptyProvider as { dispose: () => void }).dispose() + } + const fsProvider = getSshFilesystemProvider(targetId) + if (fsProvider && 'dispose' in fsProvider) { + ;(fsProvider as { dispose: () => void }).dispose() + } + + unregisterSshPtyProvider(targetId) + unregisterSshFilesystemProvider(targetId) + unregisterSshGitProvider(targetId) + initializedConnections.delete(targetId) +} + +// Why: extracted so both initial connect and reconnection use the same wiring. +// Forgetting to wire PTY events on reconnect would cause silent terminal death. +export function wireUpSshPtyEvents( + ptyProvider: SshPtyProvider, + getMainWindow: () => BrowserWindow | null +): void { + // Why: resolving the window lazily on each event (instead of capturing once) + // ensures events reach the current window even if macOS app re-activation + // creates a new BrowserWindow after the initial wiring. + ptyProvider.onData((payload) => { + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('pty:data', payload) + } + }) + ptyProvider.onExit((payload) => { + clearProviderPtyState(payload.id) + // Why: without this, the ownership entry for the exited remote PTY lingers, + // routing future lookups to the SSH provider for a PTY that no longer exists. + deletePtyOwnership(payload.id) + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('pty:exit', payload) + } + }) +} + +// Why: overlapping reconnection attempts (e.g. SSH connection flaps twice +// quickly) would cause two concurrent reestablishRelayStack calls, leaking +// relay processes and multiplexers from the first call. This map lets us +// cancel the stale attempt before starting a new one. +const reestablishAbortControllers = new Map() + +export async function reestablishRelayStack( + targetId: string, + getMainWindow: () => BrowserWindow | null, + connectionManager: SshConnectionManager | null, + activeMultiplexers: Map, + portForwardManager?: SshPortForwardManager | null +): Promise { + const conn = connectionManager?.getConnection(targetId) + if (!conn) { + return + } + + // Why: port forwards hold open local TCP servers backed by SSH channels that + // are now dead. Without cleanup, clients connecting to forwarded ports hang. + portForwardManager?.removeAllForwards(targetId) + + const prevAbort = reestablishAbortControllers.get(targetId) + if (prevAbort) { + prevAbort.abort() + } + const abortController = new AbortController() + reestablishAbortControllers.set(targetId, abortController) + + // Dispose old multiplexer with connection_lost reason + const oldMux = activeMultiplexers.get(targetId) + if (oldMux && !oldMux.isDisposed()) { + oldMux.dispose('connection_lost') + } + activeMultiplexers.delete(targetId) + + // Why: dispose notification subscriptions before unregistering so stale + // callbacks from the old multiplexer don't fire into a torn-down provider. + const oldPtyProvider = getSshPtyProvider(targetId) + if (oldPtyProvider && 'dispose' in oldPtyProvider) { + ;(oldPtyProvider as { dispose: () => void }).dispose() + } + const oldFsProvider = getSshFilesystemProvider(targetId) + if (oldFsProvider && 'dispose' in oldFsProvider) { + ;(oldFsProvider as { dispose: () => void }).dispose() + } + + unregisterSshPtyProvider(targetId) + unregisterSshFilesystemProvider(targetId) + unregisterSshGitProvider(targetId) + + try { + const { transport } = await deployAndLaunchRelay(conn) + + if (abortController.signal.aborted) { + // Why: the relay is already running on the remote. Creating a temporary + // multiplexer and immediately disposing it sends a clean shutdown to the + // relay process. Without this, the orphaned relay runs until its grace + // timer expires. + const orphanMux = new SshChannelMultiplexer(transport) + orphanMux.dispose() + return + } + + const mux = new SshChannelMultiplexer(transport) + activeMultiplexers.set(targetId, mux) + + const ptyProvider = new SshPtyProvider(targetId, mux) + registerSshPtyProvider(targetId, ptyProvider) + + const fsProvider = new SshFilesystemProvider(targetId, mux) + registerSshFilesystemProvider(targetId, fsProvider) + + const gitProvider = new SshGitProvider(targetId, mux) + registerSshGitProvider(targetId, gitProvider) + + wireUpSshPtyEvents(ptyProvider, getMainWindow) + + // Re-attach to any PTYs that were alive before the disconnect. + // The relay keeps them running during its grace period. + const ptyIds = getPtyIdsForConnection(targetId) + for (const ptyId of ptyIds) { + try { + await ptyProvider.attach(ptyId) + } catch { + // PTY may have exited during the disconnect — ignore + } + } + } catch (err) { + console.warn( + `[ssh] Failed to re-establish relay for ${targetId}: ${err instanceof Error ? err.message : String(err)}` + ) + } finally { + if (reestablishAbortControllers.get(targetId) === abortController) { + reestablishAbortControllers.delete(targetId) + } + } +} diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts new file mode 100644 index 00000000..f68a0983 --- /dev/null +++ b/src/main/ipc/ssh.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { + handleMock, + mockSshStore, + mockConnectionManager, + mockDeployAndLaunchRelay, + mockMux, + mockPtyProvider, + mockFsProvider, + mockGitProvider, + mockPortForwardManager +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + mockSshStore: { + listTargets: vi.fn().mockReturnValue([]), + getTarget: vi.fn(), + addTarget: vi.fn(), + updateTarget: vi.fn(), + removeTarget: vi.fn(), + importFromSshConfig: vi.fn().mockReturnValue([]) + }, + mockConnectionManager: { + connect: vi.fn(), + disconnect: vi.fn(), + getState: vi.fn(), + disconnectAll: vi.fn() + }, + mockDeployAndLaunchRelay: vi.fn(), + mockMux: { + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false), + onNotification: vi.fn() + }, + mockPtyProvider: { + onData: vi.fn(), + onExit: vi.fn(), + onReplay: vi.fn() + }, + mockFsProvider: {}, + mockGitProvider: {}, + mockPortForwardManager: { + addForward: vi.fn(), + removeForward: vi.fn(), + listForwards: vi.fn().mockReturnValue([]), + removeAllForwards: vi.fn(), + dispose: vi.fn() + } +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock, + on: vi.fn(), + once: vi.fn(), + removeHandler: vi.fn(), + removeAllListeners: vi.fn() + } +})) + +vi.mock('../ssh/ssh-connection-store', () => ({ + SshConnectionStore: class MockSshConnectionStore { + constructor() { + return mockSshStore + } + } +})) + +vi.mock('../ssh/ssh-connection', () => ({ + SshConnectionManager: class MockSshConnectionManager { + constructor() { + return mockConnectionManager + } + } +})) + +vi.mock('../ssh/ssh-relay-deploy', () => ({ + deployAndLaunchRelay: mockDeployAndLaunchRelay +})) + +vi.mock('../ssh/ssh-channel-multiplexer', () => ({ + SshChannelMultiplexer: class MockSshChannelMultiplexer { + constructor() { + return mockMux + } + } +})) + +vi.mock('../providers/ssh-pty-provider', () => ({ + SshPtyProvider: class MockSshPtyProvider { + constructor() { + return mockPtyProvider + } + } +})) + +vi.mock('../providers/ssh-filesystem-provider', () => ({ + SshFilesystemProvider: class MockSshFilesystemProvider { + constructor() { + return mockFsProvider + } + } +})) + +vi.mock('./pty', () => ({ + registerSshPtyProvider: vi.fn(), + unregisterSshPtyProvider: vi.fn(), + clearPtyOwnershipForConnection: vi.fn(), + getSshPtyProvider: vi.fn(), + getPtyIdsForConnection: vi.fn().mockReturnValue([]) +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + registerSshFilesystemProvider: vi.fn(), + unregisterSshFilesystemProvider: vi.fn(), + getSshFilesystemProvider: vi.fn() +})) + +vi.mock('../providers/ssh-git-provider', () => ({ + SshGitProvider: class MockSshGitProvider { + constructor() { + return mockGitProvider + } + } +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + registerSshGitProvider: vi.fn(), + unregisterSshGitProvider: vi.fn() +})) + +vi.mock('../ssh/ssh-port-forward', () => ({ + SshPortForwardManager: class MockPortForwardManager { + constructor() { + return mockPortForwardManager + } + } +})) + +import { registerSshHandlers } from './ssh' +import type { SshTarget } from '../../shared/ssh-types' + +describe('SSH IPC handlers', () => { + const handlers = new Map unknown>() + const mockStore = {} as never + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() } + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + + mockSshStore.listTargets.mockReset().mockReturnValue([]) + mockSshStore.getTarget.mockReset() + mockSshStore.addTarget.mockReset() + mockSshStore.updateTarget.mockReset() + mockSshStore.removeTarget.mockReset() + mockSshStore.importFromSshConfig.mockReset().mockReturnValue([]) + + mockConnectionManager.connect.mockReset() + mockConnectionManager.disconnect.mockReset() + mockConnectionManager.getState.mockReset() + mockConnectionManager.disconnectAll.mockReset() + + mockDeployAndLaunchRelay.mockReset().mockResolvedValue({ + transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, + platform: 'linux-x64' + }) + mockMux.dispose.mockReset() + mockMux.isDisposed.mockReset().mockReturnValue(false) + mockMux.onNotification.mockReset() + mockPtyProvider.onData.mockReset() + mockPtyProvider.onExit.mockReset() + mockPtyProvider.onReplay.mockReset() + + registerSshHandlers(mockStore, () => mockWindow as never) + }) + + it('registers all expected IPC channels', () => { + const channels = Array.from(handlers.keys()) + expect(channels).toContain('ssh:listTargets') + expect(channels).toContain('ssh:addTarget') + expect(channels).toContain('ssh:updateTarget') + expect(channels).toContain('ssh:removeTarget') + expect(channels).toContain('ssh:importConfig') + expect(channels).toContain('ssh:connect') + expect(channels).toContain('ssh:disconnect') + expect(channels).toContain('ssh:getState') + expect(channels).toContain('ssh:testConnection') + }) + + it('ssh:listTargets returns targets from store', async () => { + const mockTargets: SshTarget[] = [ + { id: 'ssh-1', label: 'Server 1', host: 'srv1.com', port: 22, username: 'admin' } + ] + mockSshStore.listTargets.mockReturnValue(mockTargets) + + const result = await handlers.get('ssh:listTargets')!(null, {}) + expect(result).toEqual(mockTargets) + }) + + it('ssh:addTarget calls store.addTarget', async () => { + const newTarget = { + label: 'New Server', + host: 'new.example.com', + port: 22, + username: 'deploy' + } + const withId = { ...newTarget, id: 'ssh-new' } + mockSshStore.addTarget.mockReturnValue(withId) + + const result = await handlers.get('ssh:addTarget')!(null, { target: newTarget }) + expect(mockSshStore.addTarget).toHaveBeenCalledWith(newTarget) + expect(result).toEqual(withId) + }) + + it('ssh:removeTarget calls store.removeTarget', async () => { + await handlers.get('ssh:removeTarget')!(null, { id: 'ssh-1' }) + expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') + }) + + it('ssh:importConfig returns imported targets', async () => { + const imported: SshTarget[] = [ + { id: 'ssh-imp', label: 'staging', host: 'staging.com', port: 22, username: '' } + ] + mockSshStore.importFromSshConfig.mockReturnValue(imported) + + const result = await handlers.get('ssh:importConfig')!(null, {}) + expect(result).toEqual(imported) + }) + + it('ssh:connect throws for unknown targetId', async () => { + mockSshStore.getTarget.mockReturnValue(undefined) + + await expect(handlers.get('ssh:connect')!(null, { targetId: 'unknown' })).rejects.toThrow( + 'SSH target "unknown" not found' + ) + }) + + it('ssh:connect calls connection manager', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) + }) + + it('ssh:disconnect calls connection manager', async () => { + mockConnectionManager.disconnect.mockResolvedValue(undefined) + + await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) + + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + }) + + it('ssh:getState returns connection state', async () => { + const state = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + mockConnectionManager.getState.mockReturnValue(state) + + const result = await handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) + expect(result).toEqual(state) + }) +}) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts new file mode 100644 index 00000000..4c645a14 --- /dev/null +++ b/src/main/ipc/ssh.ts @@ -0,0 +1,299 @@ +import { ipcMain, type BrowserWindow } from 'electron' +import type { Store } from '../persistence' +import { SshConnectionStore } from '../ssh/ssh-connection-store' +import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection' +import { deployAndLaunchRelay } from '../ssh/ssh-relay-deploy' +import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { SshPtyProvider } from '../providers/ssh-pty-provider' +import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' +import { SshGitProvider } from '../providers/ssh-git-provider' +import { registerSshPtyProvider } from './pty' +import { registerSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { registerSshGitProvider } from '../providers/ssh-git-dispatch' +import { SshPortForwardManager } from '../ssh/ssh-port-forward' +import type { SshTarget, SshConnectionState } from '../../shared/ssh-types' +import { cleanupConnection, wireUpSshPtyEvents, reestablishRelayStack } from './ssh-relay-helpers' +import { buildSshAuthCallbacks } from './ssh-auth-helpers' +import { registerSshBrowseHandler } from './ssh-browse' + +let sshStore: SshConnectionStore | null = null +let connectionManager: SshConnectionManager | null = null +let portForwardManager: SshPortForwardManager | null = null + +// Track multiplexers and providers per connection for cleanup +const activeMultiplexers = new Map() + +// Why: tracks which connections have completed initial relay setup, so +// onStateChange can distinguish "reconnected after drop" from "first connect". +const initializedConnections = new Set() + +// Why: ssh:testConnection calls connect() then disconnect(), which fires +// state-change events to the renderer. This causes worktree cards to briefly +// flash "connected" then "disconnected". Suppressing broadcasts during tests +// avoids that visual glitch. +const testingTargets = new Set() + +export function registerSshHandlers( + store: Store, + getMainWindow: () => BrowserWindow | null +): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } { + // Why: on macOS, app re-activation creates a new BrowserWindow and re-calls + // this function. ipcMain.handle() throws if a handler is already registered, + // so we must remove any prior handlers before re-registering. + for (const ch of [ + 'ssh:listTargets', + 'ssh:addTarget', + 'ssh:updateTarget', + 'ssh:removeTarget', + 'ssh:importConfig', + 'ssh:connect', + 'ssh:disconnect', + 'ssh:getState', + 'ssh:testConnection', + 'ssh:addPortForward', + 'ssh:removePortForward', + 'ssh:listPortForwards' + ]) { + ipcMain.removeHandler(ch) + } + + sshStore = new SshConnectionStore(store) + + const callbacks: SshConnectionCallbacks = { + onStateChange: (targetId: string, state: SshConnectionState) => { + if (testingTargets.has(targetId)) { + return + } + + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('ssh:state-changed', { targetId, state }) + } + + // Why: when SSH reconnects after a network blip, we must re-deploy the + // relay and rebuild the full provider stack. The old multiplexer's pending + // requests are already rejected with CONNECTION_LOST by dispose(). + if ( + state.status === 'connected' && + state.reconnectAttempt === 0 && + initializedConnections.has(targetId) + ) { + void reestablishRelayStack( + targetId, + getMainWindow, + connectionManager, + activeMultiplexers, + portForwardManager + ) + } + }, + + ...buildSshAuthCallbacks(getMainWindow) + } + + connectionManager = new SshConnectionManager(callbacks) + portForwardManager = new SshPortForwardManager() + registerSshBrowseHandler(() => connectionManager) + + // ── Target CRUD ──────────────────────────────────────────────────── + + ipcMain.handle('ssh:listTargets', () => { + return sshStore!.listTargets() + }) + + ipcMain.handle('ssh:addTarget', (_event, args: { target: Omit }) => { + return sshStore!.addTarget(args.target) + }) + + ipcMain.handle( + 'ssh:updateTarget', + (_event, args: { id: string; updates: Partial> }) => { + return sshStore!.updateTarget(args.id, args.updates) + } + ) + + ipcMain.handle('ssh:removeTarget', (_event, args: { id: string }) => { + sshStore!.removeTarget(args.id) + }) + + ipcMain.handle('ssh:importConfig', () => { + return sshStore!.importFromSshConfig() + }) + + // ── Connection lifecycle ─────────────────────────────────────────── + + ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => { + const target = sshStore!.getTarget(args.targetId) + if (!target) { + throw new Error(`SSH target "${args.targetId}" not found`) + } + + let conn + try { + conn = await connectionManager!.connect(target) + } catch (err) { + // Why: SshConnection.connect() sets its internal state to 'error', but + // the onStateChange callback may have been suppressed or the state may + // not have propagated to the renderer. Explicitly broadcast the error + // so the UI leaves 'connecting'/'host-key-verification'. + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('ssh:state-changed', { + targetId: args.targetId, + state: { + targetId: args.targetId, + status: 'error', + error: err instanceof Error ? err.message : String(err), + reconnectAttempt: 0 + } + }) + } + throw err + } + + // Deploy relay and establish multiplexer + callbacks.onStateChange(args.targetId, { + targetId: args.targetId, + status: 'deploying-relay', + error: null, + reconnectAttempt: 0 + }) + + try { + const { transport } = await deployAndLaunchRelay(conn, (status) => { + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('ssh:deploy-progress', { targetId: args.targetId, status }) + } + }) + + const mux = new SshChannelMultiplexer(transport) + activeMultiplexers.set(args.targetId, mux) + + const ptyProvider = new SshPtyProvider(args.targetId, mux) + registerSshPtyProvider(args.targetId, ptyProvider) + + const fsProvider = new SshFilesystemProvider(args.targetId, mux) + registerSshFilesystemProvider(args.targetId, fsProvider) + + const gitProvider = new SshGitProvider(args.targetId, mux) + registerSshGitProvider(args.targetId, gitProvider) + + wireUpSshPtyEvents(ptyProvider, getMainWindow) + initializedConnections.add(args.targetId) + + // Why: we manually pushed `deploying-relay` above, so the renderer's + // state is stuck there. Send `connected` directly to the renderer + // instead of going through callbacks.onStateChange, which would + // trigger the reconnection logic (reestablishRelayStack). + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('ssh:state-changed', { + targetId: args.targetId, + state: { + targetId: args.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0 + } + }) + } + } catch (err) { + // Relay deployment failed — disconnect SSH + await connectionManager!.disconnect(args.targetId) + throw err + } + + return connectionManager!.getState(args.targetId) + }) + + ipcMain.handle('ssh:disconnect', async (_event, args: { targetId: string }) => { + cleanupConnection(args.targetId, activeMultiplexers, initializedConnections, portForwardManager) + await connectionManager!.disconnect(args.targetId) + }) + + ipcMain.handle('ssh:getState', (_event, args: { targetId: string }) => { + return connectionManager!.getState(args.targetId) + }) + + ipcMain.handle('ssh:testConnection', async (_event, args: { targetId: string }) => { + const target = sshStore!.getTarget(args.targetId) + if (!target) { + throw new Error(`SSH target "${args.targetId}" not found`) + } + + // Why: testConnection calls connect() then disconnect(). If the target + // already has an active relay session, connect() would reuse the connection + // but disconnect() would tear down the entire relay stack — killing all + // active PTYs and file watchers for a "test" that was supposed to be safe. + if (initializedConnections.has(args.targetId)) { + return { success: true, state: connectionManager!.getState(args.targetId) } + } + + testingTargets.add(args.targetId) + try { + const conn = await connectionManager!.connect(target) + const state = conn.getState() + await connectionManager!.disconnect(args.targetId) + return { success: true, state } + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err) + } + } finally { + testingTargets.delete(args.targetId) + } + }) + + // ── Port forwarding ───────────────────────────────────────────────── + + ipcMain.handle( + 'ssh:addPortForward', + async ( + _event, + args: { + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + } + ) => { + const conn = connectionManager!.getConnection(args.targetId) + if (!conn) { + throw new Error(`SSH connection "${args.targetId}" not found`) + } + return portForwardManager!.addForward( + args.targetId, + conn, + args.localPort, + args.remoteHost, + args.remotePort, + args.label + ) + } + ) + + ipcMain.handle('ssh:removePortForward', (_event, args: { id: string }) => { + return portForwardManager!.removeForward(args.id) + }) + + ipcMain.handle('ssh:listPortForwards', (_event, args?: { targetId?: string }) => { + return portForwardManager!.listForwards(args?.targetId) + }) + + return { connectionManager, sshStore } +} + +export function getSshConnectionManager(): SshConnectionManager | null { + return connectionManager +} + +export function getSshConnectionStore(): SshConnectionStore | null { + return sshStore +} + +export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined { + return activeMultiplexers.get(connectionId) +} diff --git a/src/main/ipc/worktree-hooks.ts b/src/main/ipc/worktree-hooks.ts new file mode 100644 index 00000000..91eeb45e --- /dev/null +++ b/src/main/ipc/worktree-hooks.ts @@ -0,0 +1,131 @@ +// Why: extracted from worktrees.ts to keep the main IPC module under the +// max-lines threshold. Hooks IPC handlers (check, readIssueCommand, +// writeIssueCommand) are self-contained and don't interact with worktree +// creation or removal state. + +import { ipcMain } from 'electron' +import { join } from 'path' +import type { Store } from '../persistence' +import { isFolderRepo } from '../../shared/repo-kind' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + hasHooksFile, + hasUnrecognizedOrcaYamlKeys, + loadHooks, + readIssueCommand, + writeIssueCommand +} from '../hooks' + +export function registerHooksHandlers(store: Store): void { + ipcMain.removeHandler('hooks:check') + ipcMain.removeHandler('hooks:readIssueCommand') + ipcMain.removeHandler('hooks:writeIssueCommand') + + ipcMain.handle('hooks:check', async (_event, args: { repoId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo || isFolderRepo(repo)) { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + + // Why: remote repos read orca.yaml via the SSH filesystem provider. + // Parsing happens in the main process since it's CPU-cheap and avoids + // adding YAML parsing to the relay. + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + try { + const result = await fsProvider.readFile(join(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 + // Why: when a newer Orca version adds a top-level key to `orca.yaml`, older + // versions that don't recognise it return null and show "could not be parsed". + // Detecting well-formed but unrecognised keys lets the UI suggest updating + // instead of implying the file is broken. + const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) + return { + hasHooks: has, + hooks, + mayNeedUpdate + } + }) + + ipcMain.handle('hooks:readIssueCommand', async (_event, args: { repoId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo || isFolderRepo(repo)) { + return { + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' as const + } + } + + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' as const + } + } + try { + const result = await fsProvider.readFile(join(repo.path, '.orca', 'issue-command')) + return { + localContent: result.isBinary ? null : result.content, + sharedContent: null, + effectiveContent: result.isBinary ? null : result.content, + localFilePath: join(repo.path, '.orca', 'issue-command'), + source: 'local' as const + } + } catch { + return { + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' as const + } + } + } + + return readIssueCommand(repo.path) + }) + + ipcMain.handle( + 'hooks:writeIssueCommand', + async (_event, args: { repoId: string; content: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo || isFolderRepo(repo)) { + return + } + + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return + } + await fsProvider.writeFile(join(repo.path, '.orca', 'issue-command'), args.content) + return + } + + writeIssueCommand(repo.path, args.content) + } + ) +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts new file mode 100644 index 00000000..331c849e --- /dev/null +++ b/src/main/ipc/worktree-remote.ts @@ -0,0 +1,248 @@ +// Why: extracted from worktrees.ts to keep the main IPC module under the +// max-lines threshold. Worktree creation helpers (local and remote) live +// here so the IPC dispatch file stays focused on handler wiring. + +import type { BrowserWindow } from 'electron' +import { join } from 'path' +import type { Store } from '../persistence' +import type { + CreateWorktreeArgs, + CreateWorktreeResult, + Repo, + WorktreeMeta +} from '../../shared/types' +import { getPRForBranch } from '../github/client' +import { listWorktrees, addWorktree } from '../git/worktree' +import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' +import { gitExecFileSync } from '../git/runner' +import { isWslPath, parseWslPath, getWslHome } from '../wsl' +import { createSetupRunnerScript, getEffectiveHooks, shouldRunSetupForCreate } from '../hooks' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import type { SshGitProvider } from '../providers/ssh-git-provider' +import { + sanitizeWorktreeName, + computeBranchName, + computeWorktreePath, + ensurePathWithinWorkspace, + shouldSetDisplayName, + mergeWorktree, + areWorktreePathsEqual +} from './worktree-logic' +import { rebuildAuthorizedRootsCache } from './filesystem-auth' + +export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('worktrees:changed', { repoId }) + } +} + +export async function createRemoteWorktree( + args: CreateWorktreeArgs, + repo: Repo, + store: Store, + mainWindow: BrowserWindow +): Promise { + const provider = getSshGitProvider(repo.connectionId!) as SshGitProvider | undefined + if (!provider) { + throw new Error(`No git provider for connection "${repo.connectionId}"`) + } + + const settings = store.getSettings() + const requestedName = args.name + const sanitizedName = sanitizeWorktreeName(args.name) + + // Get git username from remote + let username = '' + try { + const { stdout } = await provider.exec(['config', 'user.name'], repo.path) + username = stdout.trim() + } catch { + /* no username configured */ + } + + const branchName = computeBranchName(sanitizedName, settings, username) + + // Check branch conflict on remote + try { + const { stdout } = await provider.exec(['branch', '--list', '--all', branchName], repo.path) + if (stdout.trim()) { + throw new Error(`Branch "${branchName}" already exists. Pick a different worktree name.`) + } + } catch (e) { + if (e instanceof Error && e.message.includes('already exists')) { + throw e + } + } + + // Compute worktree path relative to the repo's parent on the remote + const remotePath = `${repo.path}/../${sanitizedName}` + + // Determine base branch + let baseBranch = args.baseBranch || repo.worktreeBaseRef + if (!baseBranch) { + try { + const { stdout } = await provider.exec( + ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], + repo.path + ) + baseBranch = stdout.trim() + } catch { + baseBranch = 'origin/main' + } + } + + // Fetch latest + const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' + try { + await provider.exec(['fetch', remote], repo.path) + } catch { + /* best-effort */ + } + + // Create worktree via relay + await provider.addWorktree(repo.path, branchName, remotePath, { + base: baseBranch, + track: baseBranch.includes('/') + }) + + // Re-list to get the created worktree info + const gitWorktrees = await provider.listWorktrees(repo.path) + const created = gitWorktrees.find( + (gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(sanitizedName) + ) + if (!created) { + throw new Error('Worktree created but not found in listing') + } + + const worktreeId = `${repo.id}::${created.path}` + const metaUpdates: Partial = { + lastActivityAt: Date.now(), + ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) + ? { displayName: requestedName } + : {}) + } + const meta = store.setWorktreeMeta(worktreeId, metaUpdates) + const worktree = mergeWorktree(repo.id, created, meta) + + notifyWorktreesChanged(mainWindow, repo.id) + return { worktree } +} + +export async function createLocalWorktree( + args: CreateWorktreeArgs, + repo: Repo, + store: Store, + mainWindow: BrowserWindow +): Promise { + const settings = store.getSettings() + + const requestedName = args.name + const sanitizedName = sanitizeWorktreeName(args.name) + + // Compute branch name with prefix + const username = getGitUsername(repo.path) + const branchName = computeBranchName(sanitizedName, settings, username) + + const branchConflictKind = await getBranchConflictKind(repo.path, branchName) + if (branchConflictKind) { + throw new Error( + `Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}. Pick a different worktree name.` + ) + } + + // Why: the UI resolves PR status by branch name alone. Reusing a historical + // PR head name would make a fresh worktree inherit that old merged/closed PR + // immediately, so we reject the name instead of silently suffixing it. + // The lookup is best-effort — don't block creation if GitHub is unreachable. + let existingPR: Awaited> | null = null + try { + existingPR = await getPRForBranch(repo.path, branchName) + } catch { + // GitHub API may be unreachable, rate-limited, or token missing + } + if (existingPR) { + throw new Error( + `Branch "${branchName}" already has PR #${existingPR.number}. Pick a different worktree name.` + ) + } + + // Compute worktree path + let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings) + // Why: WSL worktrees live under ~/orca/workspaces inside the WSL + // filesystem. Validate against that root, not the Windows workspace dir. + // If WSL home lookup fails, keep using the configured workspace root so + // the path traversal guard still runs on the fallback path. + const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null + const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null + const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir + worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot) + + // Determine base branch + const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + const setupScript = getEffectiveHooks(repo)?.scripts.setup + // Why: `ask` is a pre-create choice gate, not a post-create side effect. + // Resolve it before mutating git state so missing UI input cannot strand + // a real worktree on disk while the renderer reports "create failed". + const shouldLaunchSetup = setupScript ? shouldRunSetupForCreate(repo, args.setupDecision) : false + + // Fetch latest from remote so the worktree starts with up-to-date content + const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' + try { + gitExecFileSync(['fetch', remote], { cwd: repo.path }) + } catch { + // Fetch is best-effort — don't block worktree creation if offline + } + + addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + + // Re-list to get the freshly created worktree info + const gitWorktrees = await listWorktrees(repo.path) + const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath)) + if (!created) { + throw new Error('Worktree created but not found in listing') + } + + const worktreeId = `${repo.id}::${created.path}` + const metaUpdates: Partial = { + // Stamp activity so the worktree sorts into its final position + // immediately — prevents scroll-to-reveal racing with a later + // bumpWorktreeActivity that would re-sort the list. + lastActivityAt: Date.now(), + ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) + ? { displayName: requestedName } + : {}) + } + const meta = store.setWorktreeMeta(worktreeId, metaUpdates) + const worktree = mergeWorktree(repo.id, created, meta) + await rebuildAuthorizedRootsCache(store) + + let setup: CreateWorktreeResult['setup'] + if (setupScript && shouldLaunchSetup) { + try { + // Why: setup now runs in a visible terminal owned by the renderer so users + // can inspect failures, answer prompts, and rerun it. The main process only + // resolves policy and writes the runner script; it must not execute setup + // itself anymore or we would reintroduce the hidden background-hook behavior. + // + // Why: the git worktree already exists at this point. If runner generation + // fails, surfacing the error as a hard create failure would lie to the UI + // about the underlying git state and strand a real worktree on disk. + // Degrade to "created without setup launch" instead. + setup = createSetupRunnerScript(repo, worktreePath, setupScript) + } catch (error) { + console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) + } + } + + notifyWorktreesChanged(mainWindow, repo.id) + return { + worktree, + ...(setup ? { setup } : {}) + } +} diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index b7f2c961..b81d2341 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -9,37 +9,31 @@ import type { Worktree, WorktreeMeta } from '../../shared/types' -import { getPRForBranch } from '../github/client' -import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree' -import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' -import { gitExecFileAsync, gitExecFileSync } from '../git/runner' -import { isWslPath, parseWslPath, getWslHome } from '../wsl' -import { join } from 'path' -import { listRepoWorktrees } from '../repo-worktrees' +import { removeWorktree } from '../git/worktree' +import { gitExecFileAsync } from '../git/runner' +import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { createIssueCommandRunnerScript, - createSetupRunnerScript, getEffectiveHooks, loadHooks, readIssueCommand, runHook, hasHooksFile, hasUnrecognizedOrcaYamlKeys, - shouldRunSetupForCreate, writeIssueCommand } from '../hooks' import { - sanitizeWorktreeName, - computeBranchName, - computeWorktreePath, - ensurePathWithinWorkspace, - shouldSetDisplayName, mergeWorktree, parseWorktreeId, - areWorktreePathsEqual, formatWorktreeRemovalError, isOrphanedWorktreeError } from './worktree-logic' +import { + createLocalWorktree, + createRemoteWorktree, + notifyWorktreesChanged +} from './worktree-remote' import { rebuildAuthorizedRootsCache, ensureAuthorizedRootsCache } from './filesystem-auth' export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store): void { @@ -65,7 +59,20 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store const allWorktrees: Worktree[] = [] for (const repo of repos) { - const gitWorktrees = await listRepoWorktrees(repo) + let gitWorktrees + if (isFolderRepo(repo)) { + gitWorktrees = [createFolderWorktree(repo)] + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + // Why: when SSH is disconnected the provider is null. Skip this repo + // so the renderer keeps its cached worktree list instead of clearing it. + if (!provider) { + continue + } + gitWorktrees = await provider.listWorktrees(repo.path) + } else { + gitWorktrees = await listRepoWorktrees(repo) + } for (const gw of gitWorktrees) { const worktreeId = `${repo.id}::${gw.path}` const meta = store.getWorktreeMeta(worktreeId) @@ -86,7 +93,21 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store return [] } - const gitWorktrees = await listRepoWorktrees(repo) + let gitWorktrees + if (isFolderRepo(repo)) { + gitWorktrees = [createFolderWorktree(repo)] + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + // Why: when SSH is disconnected the provider is null. Throwing here + // makes the renderer's fetchWorktrees catch block preserve its cached + // worktree list instead of replacing it with an empty array. + if (!provider) { + throw new Error(`SSH connection "${repo.connectionId}" is not active`) + } + gitWorktrees = await provider.listWorktrees(repo.path) + } else { + gitWorktrees = await listRepoWorktrees(repo) + } return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` const meta = store.getWorktreeMeta(worktreeId) @@ -105,119 +126,12 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store throw new Error('Folder mode does not support creating worktrees.') } - const settings = store.getSettings() - - const requestedName = args.name - const sanitizedName = sanitizeWorktreeName(args.name) - - // Compute branch name with prefix - const username = getGitUsername(repo.path) - const branchName = computeBranchName(sanitizedName, settings, username) - - const branchConflictKind = await getBranchConflictKind(repo.path, branchName) - if (branchConflictKind) { - throw new Error( - `Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}. Pick a different worktree name.` - ) + // Remote repos route all git operations through the relay + if (repo.connectionId) { + return createRemoteWorktree(args, repo, store, mainWindow) } - // Why: the UI resolves PR status by branch name alone. Reusing a historical - // PR head name would make a fresh worktree inherit that old merged/closed PR - // immediately, so we reject the name instead of silently suffixing it. - // The lookup is best-effort — don't block creation if GitHub is unreachable. - let existingPR: Awaited> | null = null - try { - existingPR = await getPRForBranch(repo.path, branchName) - } catch { - // GitHub API may be unreachable, rate-limited, or token missing - } - if (existingPR) { - throw new Error( - `Branch "${branchName}" already has PR #${existingPR.number}. Pick a different worktree name.` - ) - } - - // Compute worktree path - let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings) - // Why: WSL worktrees live under ~/orca/workspaces inside the WSL - // filesystem. Validate against that root, not the Windows workspace dir. - // If WSL home lookup fails, keep using the configured workspace root so - // the path traversal guard still runs on the fallback path. - const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null - const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null - const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir - worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot) - - // Determine base branch - const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) - const setupScript = getEffectiveHooks(repo)?.scripts.setup - // Why: `ask` is a pre-create choice gate, not a post-create side effect. - // Resolve it before mutating git state so missing UI input cannot strand - // a real worktree on disk while the renderer reports "create failed". - const shouldLaunchSetup = setupScript - ? shouldRunSetupForCreate(repo, args.setupDecision) - : false - - // Fetch latest from remote so the worktree starts with up-to-date content - const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' - try { - gitExecFileSync(['fetch', remote], { cwd: repo.path }) - } catch { - // Fetch is best-effort — don't block worktree creation if offline - } - - addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) - - // Re-list to get the freshly created worktree info - const gitWorktrees = await listWorktrees(repo.path) - const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath)) - if (!created) { - throw new Error('Worktree created but not found in listing') - } - - const worktreeId = `${repo.id}::${created.path}` - const metaUpdates: Partial = { - // Stamp activity so the worktree sorts into its final position - // immediately — prevents scroll-to-reveal racing with a later - // bumpWorktreeActivity that would re-sort the list. - lastActivityAt: Date.now(), - ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) - ? { displayName: requestedName } - : {}) - } - const meta = store.setWorktreeMeta(worktreeId, metaUpdates) - const worktree = mergeWorktree(repo.id, created, meta) - await rebuildAuthorizedRootsCache(store) - - let setup: CreateWorktreeResult['setup'] - if (setupScript && shouldLaunchSetup) { - try { - // Why: setup now runs in a visible terminal owned by the renderer so users - // can inspect failures, answer prompts, and rerun it. The main process only - // resolves policy and writes the runner script; it must not execute setup - // itself anymore or we would reintroduce the hidden background-hook behavior. - // - // Why: the git worktree already exists at this point. If runner generation - // fails, surfacing the error as a hard create failure would lie to the UI - // about the underlying git state and strand a real worktree on disk. - // Degrade to "created without setup launch" instead. - setup = createSetupRunnerScript(repo, worktreePath, setupScript) - } catch (error) { - console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) - } - } - - notifyWorktreesChanged(mainWindow, repo.id) - return { - worktree, - ...(setup ? { setup } : {}) - } + return createLocalWorktree(args, repo, store, mainWindow) } ) @@ -233,6 +147,17 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store 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(worktreePath, args.force) + store.removeWorktreeMeta(args.worktreeId) + notifyWorktreesChanged(mainWindow, repoId) + return + } + // Run archive hook before removal const hooks = getEffectiveHooks(repo) if (hooks?.scripts.archive) { @@ -355,9 +280,3 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store writeIssueCommand(repo.path, args.content) }) } - -function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('worktrees:changed', { repoId }) - } -} diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 8f33daee..6a5203c3 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3,6 +3,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from ' import { join, dirname } from 'path' import { homedir } from 'os' import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types' +import type { SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' import { getGitUsername } from './git/repo' import { @@ -83,7 +84,8 @@ export class Store { ...parsed.ui, sortBy: normalizeSortBy(parsed.ui?.sortBy) }, - workspaceSession: { ...defaults.workspaceSession, ...parsed.workspaceSession } + workspaceSession: { ...defaults.workspaceSession, ...parsed.workspaceSession }, + sshTargets: parsed.sshTargets ?? [] } } } catch (err) { @@ -278,6 +280,42 @@ export class Store { this.scheduleSave() } + // ── SSH Targets ──────────────────────────────────────────────────── + + getSshTargets(): SshTarget[] { + return this.state.sshTargets ?? [] + } + + getSshTarget(id: string): SshTarget | undefined { + return this.state.sshTargets?.find((t) => t.id === id) + } + + addSshTarget(target: SshTarget): void { + if (!this.state.sshTargets) { + this.state.sshTargets = [] + } + this.state.sshTargets.push(target) + this.scheduleSave() + } + + updateSshTarget(id: string, updates: Partial>): SshTarget | null { + const target = this.state.sshTargets?.find((t) => t.id === id) + if (!target) { + return null + } + Object.assign(target, updates) + this.scheduleSave() + return { ...target } + } + + removeSshTarget(id: string): void { + if (!this.state.sshTargets) { + return + } + this.state.sshTargets = this.state.sshTargets.filter((t) => t.id !== id) + this.scheduleSave() + } + // ── Flush (for shutdown) ─────────────────────────────────────────── flush(): void { diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts new file mode 100644 index 00000000..b902f796 --- /dev/null +++ b/src/main/providers/local-pty-provider.test.ts @@ -0,0 +1,272 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { existsSyncMock, statSyncMock, accessSyncMock, spawnMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + statSyncMock: vi.fn(), + accessSyncMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('fs', () => ({ + existsSync: existsSyncMock, + statSync: statSyncMock, + accessSync: accessSyncMock, + chmodSync: vi.fn(), + constants: { X_OK: 1 } +})) + +vi.mock('node-pty', () => ({ + spawn: spawnMock +})) + +vi.mock('../wsl', () => ({ + parseWslPath: () => null +})) + +import { LocalPtyProvider } from './local-pty-provider' + +describe('LocalPtyProvider', () => { + let provider: LocalPtyProvider + let mockProc: { + onData: ReturnType + onExit: ReturnType + write: ReturnType + resize: ReturnType + kill: ReturnType + process: string + pid: number + } + let exitCb: ((info: { exitCode: number }) => void) | undefined + let origShell: string | undefined + + beforeEach(() => { + origShell = process.env.SHELL + process.env.SHELL = '/bin/zsh' + + existsSyncMock.mockReturnValue(true) + statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 }) + accessSyncMock.mockReturnValue(undefined) + + exitCb = undefined + mockProc = { + onData: vi.fn(), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + }), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => { + exitCb?.({ exitCode: -1 }) + }), + process: 'zsh', + pid: 12345 + } + spawnMock.mockReturnValue(mockProc) + + provider = new LocalPtyProvider() + }) + + afterEach(() => { + if (origShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = origShell + } + }) + + describe('spawn', () => { + it('returns a unique PTY id', async () => { + const result = await provider.spawn({ cols: 80, rows: 24 }) + expect(result.id).toBeTruthy() + expect(typeof result.id).toBe('string') + }) + + it('calls node-pty spawn with correct args', async () => { + await provider.spawn({ cols: 120, rows: 40, cwd: '/tmp' }) + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + cols: 120, + rows: 40, + cwd: '/tmp' + }) + ) + }) + + it('throws when cwd does not exist', async () => { + existsSyncMock.mockImplementation((p: string) => p !== '/nonexistent') + await expect(provider.spawn({ cols: 80, rows: 24, cwd: '/nonexistent' })).rejects.toThrow( + 'does not exist' + ) + }) + + it('invokes onSpawned callback', async () => { + const onSpawned = vi.fn() + provider.configure({ onSpawned }) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + expect(onSpawned).toHaveBeenCalledWith(id) + }) + + it('invokes buildSpawnEnv callback to customize environment', async () => { + const buildSpawnEnv = vi.fn((_id: string, env: Record) => { + env.CUSTOM_VAR = 'custom-value' + return env + }) + provider.configure({ buildSpawnEnv }) + await provider.spawn({ cols: 80, rows: 24 }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value') + }) + }) + + describe('write', () => { + it('writes data to the PTY process', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + provider.write(id, 'hello') + expect(mockProc.write).toHaveBeenCalledWith('hello') + }) + + it('is a no-op for unknown PTY ids', () => { + provider.write('nonexistent', 'hello') + expect(mockProc.write).not.toHaveBeenCalled() + }) + }) + + describe('resize', () => { + it('resizes the PTY process', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + provider.resize(id, 120, 40) + expect(mockProc.resize).toHaveBeenCalledWith(120, 40) + }) + }) + + describe('shutdown', () => { + it('kills the PTY process', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + await provider.shutdown(id, true) + expect(mockProc.kill).toHaveBeenCalled() + }) + + it('invokes onExit callback via the node-pty exit handler', async () => { + const onExit = vi.fn() + provider.configure({ onExit }) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + await provider.shutdown(id, true) + expect(onExit).toHaveBeenCalledWith(id, -1) + }) + + it('is a no-op for unknown PTY ids', async () => { + await provider.shutdown('nonexistent', true) + expect(mockProc.kill).not.toHaveBeenCalled() + }) + }) + + describe('hasChildProcesses', () => { + it('returns false when foreground process matches shell', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + expect(await provider.hasChildProcesses(id)).toBe(false) + }) + + it('returns true when foreground process differs from shell', async () => { + mockProc.process = 'node' + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + expect(await provider.hasChildProcesses(id)).toBe(true) + }) + + it('returns false for unknown PTY ids', async () => { + expect(await provider.hasChildProcesses('nonexistent')).toBe(false) + }) + }) + + describe('getForegroundProcess', () => { + it('returns the process name', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + expect(await provider.getForegroundProcess(id)).toBe('zsh') + }) + + it('returns null for unknown PTY ids', async () => { + expect(await provider.getForegroundProcess('nonexistent')).toBeNull() + }) + }) + + describe('event listeners', () => { + it('notifies data listeners when PTY produces output', async () => { + const dataHandler = vi.fn() + provider.onData(dataHandler) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + // Simulate node-pty data event + const onDataCb = mockProc.onData.mock.calls[0][0] + onDataCb('hello world') + + expect(dataHandler).toHaveBeenCalledWith({ id, data: 'hello world' }) + }) + + it('notifies exit listeners when PTY exits', async () => { + const exitHandler = vi.fn() + provider.onExit(exitHandler) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + // Simulate node-pty exit event + exitCb?.({ exitCode: 0 }) + + expect(exitHandler).toHaveBeenCalledWith({ id, code: 0 }) + }) + + it('allows unsubscribing from events', async () => { + const dataHandler = vi.fn() + const unsub = provider.onData(dataHandler) + const { id: _id } = await provider.spawn({ cols: 80, rows: 24 }) + + unsub() + const onDataCb = mockProc.onData.mock.calls[0][0] + onDataCb('hello') + + expect(dataHandler).not.toHaveBeenCalled() + }) + }) + + describe('listProcesses', () => { + it('returns spawned PTYs', async () => { + const before = await provider.listProcesses() + await provider.spawn({ cols: 80, rows: 24 }) + await provider.spawn({ cols: 80, rows: 24 }) + const after = await provider.listProcesses() + expect(after.length - before.length).toBe(2) + const newEntries = after.slice(before.length) + expect(newEntries[0]).toHaveProperty('id') + expect(newEntries[0]).toHaveProperty('title', 'zsh') + }) + }) + + describe('getDefaultShell', () => { + it('returns SHELL env var on Unix', async () => { + const originalShell = process.env.SHELL + try { + process.env.SHELL = '/bin/bash' + expect(await provider.getDefaultShell()).toBe('/bin/bash') + } finally { + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + } + }) + }) + + describe('killAll', () => { + it('kills all PTY processes', async () => { + await provider.spawn({ cols: 80, rows: 24 }) + await provider.spawn({ cols: 80, rows: 24 }) + + provider.killAll() + + expect(mockProc.kill).toHaveBeenCalled() + const list = await provider.listProcesses() + expect(list).toHaveLength(0) + }) + }) +}) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts new file mode 100644 index 00000000..9df6f006 --- /dev/null +++ b/src/main/providers/local-pty-provider.ts @@ -0,0 +1,441 @@ +/* eslint-disable max-lines -- Why: shell-ready startup command integration adds +~70 lines of scanner/promise wiring to spawn(). Splitting the method would scatter +tightly coupled PTY lifecycle logic (scan → ready → write → exit cleanup) across +files without a cleaner ownership seam. */ +import { basename, win32 as pathWin32 } from 'path' +import { existsSync } from 'fs' +import * as pty from 'node-pty' +import { parseWslPath } from '../wsl' +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import { + ensureNodePtySpawnHelperExecutable, + validateWorkingDirectory, + spawnShellWithFallback +} from './local-pty-utils' +import { + getShellReadyLaunchConfig, + createShellReadyScanState, + scanForShellReady, + writeStartupCommandWhenShellReady, + STARTUP_COMMAND_READY_MAX_WAIT_MS +} from './local-pty-shell-ready' + +let ptyCounter = 0 +const ptyProcesses = new Map() +const ptyShellName = new Map() +// Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction +// callbacks. If the PTY is killed without disposing these listeners, the +// stale callbacks survive into node::FreeEnvironment() where NAPI attempts +// to invoke/clean them up on a destroyed environment, triggering a SIGABRT. +const ptyDisposables = new Map void }[]>() + +let loadGeneration = 0 +const ptyLoadGeneration = new Map() + +type DataCallback = (payload: { id: string; data: string }) => void +type ExitCallback = (payload: { id: string; code: number }) => void + +const dataListeners = new Set() +const exitListeners = new Set() + +function disposePtyListeners(id: string): void { + const disposables = ptyDisposables.get(id) + if (disposables) { + for (const d of disposables) { + d.dispose() + } + ptyDisposables.delete(id) + } +} + +function clearPtyState(id: string): void { + disposePtyListeners(id) + ptyProcesses.delete(id) + ptyShellName.delete(id) + ptyLoadGeneration.delete(id) +} + +function safeKillAndClean(id: string, proc: pty.IPty): void { + disposePtyListeners(id) + try { + proc.kill() + } catch { + /* Process may already be dead */ + } + clearPtyState(id) +} + +export type LocalPtyProviderOptions = { + buildSpawnEnv?: (id: string, baseEnv: Record) => Record + onSpawned?: (id: string) => void + onExit?: (id: string, code: number) => void + onData?: (id: string, data: string, timestamp: number) => void +} + +export class LocalPtyProvider implements IPtyProvider { + private opts: LocalPtyProviderOptions + + constructor(opts: LocalPtyProviderOptions = {}) { + this.opts = opts + } + + /** Reconfigure the provider with new hooks (e.g. after window re-creation). */ + configure(opts: LocalPtyProviderOptions): void { + this.opts = opts + } + + async spawn(args: PtySpawnOptions): Promise { + const id = String(++ptyCounter) + + const defaultCwd = + process.platform === 'win32' + ? process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\' + : process.env.HOME || '/' + + const cwd = args.cwd || defaultCwd + const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null + + let shellPath: string + let shellArgs: string[] + let effectiveCwd: string + let validationCwd: string + let shellReadyLaunch: ReturnType | null = null + if (wslInfo) { + const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''") + shellPath = 'wsl.exe' + shellArgs = ['-d', wslInfo.distro, '--', 'bash', '-c', `cd '${escapedCwd}' && exec bash -l`] + effectiveCwd = process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\' + validationCwd = cwd + } else if (process.platform === 'win32') { + shellPath = process.env.COMSPEC || 'powershell.exe' + // Why: use path.win32.basename so backslash-separated Windows paths + // are parsed correctly even when tests mock process.platform on Linux CI. + const shellBasename = pathWin32.basename(shellPath).toLowerCase() + // Why: On CJK Windows (Chinese, Japanese, Korean), the console code page + // defaults to the system ANSI code page (e.g. 936/GBK for Chinese). + // ConPTY encodes its output pipe using this code page, but node-pty + // always decodes as UTF-8. Without switching to code page 65001 (UTF-8), + // multi-byte CJK characters are garbled because the GBK/Shift-JIS/EUC-KR + // byte sequences are misinterpreted as UTF-8. + if (shellBasename === 'cmd.exe') { + shellArgs = ['/K', 'chcp 65001 > nul'] + } else if (shellBasename === 'powershell.exe' || shellBasename === 'pwsh.exe') { + // Why: `-NoExit -Command` alone skips the user's $PROFILE, breaking + // custom prompts (oh-my-posh, starship), aliases, and PSReadLine + // configuration. Dot-sourcing $PROFILE first restores the normal + // startup experience. + shellArgs = [ + '-NoExit', + '-Command', + 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' + ] + } else { + shellArgs = [] + } + effectiveCwd = cwd + validationCwd = cwd + } else { + shellPath = args.env?.SHELL || process.env.SHELL || '/bin/zsh' + shellReadyLaunch = args.command ? getShellReadyLaunchConfig(shellPath) : null + shellArgs = shellReadyLaunch?.args ?? ['-l'] + effectiveCwd = cwd + validationCwd = cwd + } + + ensureNodePtySpawnHelperExecutable() + validateWorkingDirectory(validationCwd) + + const spawnEnv: Record = { + ...process.env, + ...args.env, + ...shellReadyLaunch?.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'Orca', + FORCE_HYPERLINK: '1' + } as Record + + spawnEnv.LANG ??= 'en_US.UTF-8' + + // Why: On Windows, LANG alone does not control the console code page. + // Programs like Python and Node.js check their own encoding env vars + // independently. PYTHONUTF8=1 makes Python use UTF-8 for stdio regardless + // of the Windows console code page, preventing garbled CJK output from + // Python scripts run inside the terminal. + if (process.platform === 'win32') { + spawnEnv.PYTHONUTF8 ??= '1' + } + + const finalEnv = this.opts.buildSpawnEnv ? this.opts.buildSpawnEnv(id, spawnEnv) : spawnEnv + + const spawnResult = spawnShellWithFallback({ + shellPath, + shellArgs, + cols: args.cols, + rows: args.rows, + cwd: effectiveCwd, + env: finalEnv, + ptySpawn: pty.spawn, + getShellReadyConfig: args.command ? (shell) => getShellReadyLaunchConfig(shell) : undefined + }) + shellPath = spawnResult.shellPath + + if (process.platform !== 'win32') { + finalEnv.SHELL = shellPath + } + + const proc = spawnResult.process + ptyProcesses.set(id, proc) + ptyShellName.set(id, basename(shellPath)) + ptyLoadGeneration.set(id, loadGeneration) + this.opts.onSpawned?.(id) + + // Shell-ready startup command support + let resolveShellReady: (() => void) | null = null + let shellReadyTimeout: ReturnType | null = null + const shellReadyScanState = shellReadyLaunch?.supportsReadyMarker + ? createShellReadyScanState() + : null + const shellReadyPromise = args.command + ? new Promise((resolve) => { + resolveShellReady = resolve + }) + : Promise.resolve() + const finishShellReady = (): void => { + if (!resolveShellReady) { + return + } + if (shellReadyTimeout) { + clearTimeout(shellReadyTimeout) + shellReadyTimeout = null + } + const resolve = resolveShellReady + resolveShellReady = null + resolve() + } + if (args.command) { + if (shellReadyLaunch?.supportsReadyMarker) { + shellReadyTimeout = setTimeout(() => { + finishShellReady() + }, STARTUP_COMMAND_READY_MAX_WAIT_MS) + } else { + finishShellReady() + } + } + let startupCommandCleanup: (() => void) | null = null + + const disposables: { dispose: () => void }[] = [] + const onDataDisposable = proc.onData((rawData) => { + let data = rawData + if (shellReadyScanState && resolveShellReady) { + const scanned = scanForShellReady(shellReadyScanState, rawData) + data = scanned.output + if (scanned.matched) { + finishShellReady() + } + } + if (data.length === 0) { + return + } + this.opts.onData?.(id, data, Date.now()) + for (const cb of dataListeners) { + cb({ id, data }) + } + }) + if (onDataDisposable) { + disposables.push(onDataDisposable) + } + + const onExitDisposable = proc.onExit(({ exitCode }) => { + if (shellReadyTimeout) { + clearTimeout(shellReadyTimeout) + shellReadyTimeout = null + } + startupCommandCleanup?.() + clearPtyState(id) + this.opts.onExit?.(id, exitCode) + for (const cb of exitListeners) { + cb({ id, code: exitCode }) + } + }) + if (onExitDisposable) { + disposables.push(onExitDisposable) + } + ptyDisposables.set(id, disposables) + + if (args.command) { + writeStartupCommandWhenShellReady(shellReadyPromise, proc, args.command, (cleanup) => { + startupCommandCleanup = cleanup + }) + } + + return { id } + } + + // Local PTYs are always attached -- no-op. Remote providers use this to resubscribe. + async attach(_id: string): Promise {} + write(id: string, data: string): void { + ptyProcesses.get(id)?.write(data) + } + resize(id: string, cols: number, rows: number): void { + ptyProcesses.get(id)?.resize(cols, rows) + } + + async shutdown(id: string, _immediate: boolean): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return + } + // Why: disposePtyListeners removes the onExit callback, so the natural + // exit cleanup path from node-pty won't fire. Cleanup and notification + // must happen unconditionally after the try/catch. + disposePtyListeners(id) + try { + proc.kill() + } catch { + /* Process may already be dead */ + } + clearPtyState(id) + this.opts.onExit?.(id, -1) + for (const cb of exitListeners) { + cb({ id, code: -1 }) + } + } + + async sendSignal(id: string, signal: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return + } + try { + process.kill(proc.pid, signal) + } catch { + /* Process may already be dead */ + } + } + + async getCwd(id: string): Promise { + if (!ptyProcesses.has(id)) { + throw new Error(`PTY ${id} not found`) + } + // node-pty doesn't expose cwd; would need /proc on Linux or lsof on macOS + return '' + } + async getInitialCwd(_id: string): Promise { + return '' + } + async clearBuffer(_id: string): Promise { + /* handled client-side in xterm.js */ + } + acknowledgeDataEvent(_id: string, _charCount: number): void { + /* no flow control for local */ + } + + async hasChildProcesses(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return false + } + try { + const foreground = proc.process + const shell = ptyShellName.get(id) + if (!shell) { + return true + } + return foreground !== shell + } catch { + return false + } + } + + async getForegroundProcess(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return null + } + try { + return proc.process || null + } catch { + return null + } + } + + async serialize(_ids: string[]): Promise { + return '{}' + } + async revive(_state: string): Promise { + /* re-spawning handles local revival */ + } + + async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ + id, + cwd: '', + title: proc.process || ptyShellName.get(id) || 'shell' + })) + } + + async getDefaultShell(): Promise { + if (process.platform === 'win32') { + return process.env.COMSPEC || 'powershell.exe' + } + return process.env.SHELL || '/bin/zsh' + } + + async getProfiles(): Promise<{ name: string; path: string }[]> { + if (process.platform === 'win32') { + return [ + { name: 'PowerShell', path: 'powershell.exe' }, + { name: 'Command Prompt', path: 'cmd.exe' } + ] + } + const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] + return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) + } + + onData(callback: DataCallback): () => void { + dataListeners.add(callback) + return () => dataListeners.delete(callback) + } + + // Local PTYs don't replay -- this is for remote reconnection + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { + return () => {} + } + + onExit(callback: ExitCallback): () => void { + exitListeners.add(callback) + return () => exitListeners.delete(callback) + } + + // ─── Local-only helpers (not part of IPtyProvider interface) ─────── + + /** Kill orphaned PTYs from previous page loads. */ + killOrphanedPtys(currentGeneration: number): { id: string }[] { + const killed: { id: string }[] = [] + for (const [id, proc] of ptyProcesses) { + if ((ptyLoadGeneration.get(id) ?? -1) < currentGeneration) { + safeKillAndClean(id, proc) + killed.push({ id }) + } + } + return killed + } + + /** Advance the load generation counter (called on renderer reload). */ + advanceGeneration(): number { + return ++loadGeneration + } + + /** Get a writable reference to a PTY (for runtime controller). */ + getPtyProcess(id: string): pty.IPty | undefined { + return ptyProcesses.get(id) + } + + /** Kill all PTYs. Call on app quit. */ + killAll(): void { + for (const [id, proc] of ptyProcesses) { + safeKillAndClean(id, proc) + } + } +} diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts new file mode 100644 index 00000000..db2de1d7 --- /dev/null +++ b/src/main/providers/local-pty-shell-ready.ts @@ -0,0 +1,237 @@ +/** + * Shell-ready startup command support for local PTYs. + * + * Why: when Orca needs to inject a startup command (e.g. issue command runner), + * it must wait until the shell has fully initialized before writing. This module + * provides shell wrapper rcfiles that emit an OSC 133;A marker after startup, + * and a data scanner that detects that marker so the command can be written at + * the right time. + */ +import { basename } from 'path' +import { mkdirSync, writeFileSync, chmodSync } from 'fs' +import { app } from 'electron' +import type * as pty from 'node-pty' + +let didEnsureShellReadyWrappers = false + +function quotePosixSingle(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +const STARTUP_COMMAND_READY_MAX_WAIT_MS = 1500 +const OSC_133_A = '\x1b]133;A' + +// ── OSC 133;A scanner ─────────────────────────────────────────────── + +export type ShellReadyScanState = { + matchPos: number + heldBytes: string +} + +export function createShellReadyScanState(): ShellReadyScanState { + return { matchPos: 0, heldBytes: '' } +} + +export function scanForShellReady( + state: ShellReadyScanState, + data: string +): { output: string; matched: boolean } { + let output = '' + + for (let i = 0; i < data.length; i += 1) { + const ch = data[i] as string + if (state.matchPos < OSC_133_A.length) { + if (ch === OSC_133_A[state.matchPos]) { + state.heldBytes += ch + state.matchPos += 1 + } else { + output += state.heldBytes + state.heldBytes = '' + state.matchPos = 0 + if (ch === OSC_133_A[0]) { + state.heldBytes = ch + state.matchPos = 1 + } else { + output += ch + } + } + } else if (ch === '\x07') { + const remaining = data.slice(i + 1) + state.heldBytes = '' + state.matchPos = 0 + return { output: output + remaining, matched: true } + } else { + state.heldBytes += ch + } + } + + return { output, matched: false } +} + +// ── Shell wrapper files ───────────────────────────────────────────── + +function getShellReadyWrapperRoot(): string { + return `${app.getPath('userData')}/shell-ready` +} + +export function getBashShellReadyRcfileContent(): string { + return `# Orca bash shell-ready wrapper +[[ -f /etc/profile ]] && source /etc/profile +if [[ -f "$HOME/.bash_profile" ]]; then + source "$HOME/.bash_profile" +elif [[ -f "$HOME/.bash_login" ]]; then + source "$HOME/.bash_login" +elif [[ -f "$HOME/.profile" ]]; then + source "$HOME/.profile" +fi +# Why: preserve bash's normal login-shell contract. Many users already source +# ~/.bashrc from ~/.bash_profile; forcing ~/.bashrc again here would duplicate +# PATH edits, hooks, and prompt init in Orca startup-command shells. +# Why: append the marker through PROMPT_COMMAND so it fires after the login +# startup files have rebuilt the prompt, matching Superset's "shell ready" +# contract without re-running user rc files. +__orca_prompt_mark() { + printf "\\033]133;A\\007" +} +if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then + PROMPT_COMMAND=("\${PROMPT_COMMAND[@]}" "__orca_prompt_mark") +else + _orca_prev_prompt_command="\${PROMPT_COMMAND}" + if [[ -n "\${_orca_prev_prompt_command}" ]]; then + PROMPT_COMMAND="\${_orca_prev_prompt_command};__orca_prompt_mark" + else + PROMPT_COMMAND="__orca_prompt_mark" + fi +fi +` +} + +function ensureShellReadyWrappers(): void { + if (didEnsureShellReadyWrappers || process.platform === 'win32') { + return + } + didEnsureShellReadyWrappers = true + + const root = getShellReadyWrapperRoot() + const zshDir = `${root}/zsh` + const bashDir = `${root}/bash` + + const zshEnv = `# Orca zsh shell-ready wrapper +export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" +export ZDOTDIR=${quotePosixSingle(zshDir)} +` + const zshProfile = `# Orca zsh shell-ready wrapper +_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile" +` + const zshRc = `# Orca zsh shell-ready wrapper +_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then + source "$_orca_home/.zshrc" +fi +` + const zshLogin = `# Orca zsh shell-ready wrapper +_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then + source "$_orca_home/.zlogin" +fi +# Why: emit OSC 133;A only after the user's startup hooks finish so Orca knows +# the prompt is actually ready for a long startup command paste. +__orca_prompt_mark() { + printf "\\033]133;A\\007" +} +precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) +` + const bashRc = getBashShellReadyRcfileContent() + + const files = [ + [`${zshDir}/.zshenv`, zshEnv], + [`${zshDir}/.zprofile`, zshProfile], + [`${zshDir}/.zshrc`, zshRc], + [`${zshDir}/.zlogin`, zshLogin], + [`${bashDir}/rcfile`, bashRc] + ] as const + + for (const [path, content] of files) { + const dir = path.slice(0, path.lastIndexOf('/')) + mkdirSync(dir, { recursive: true }) + writeFileSync(path, content, 'utf8') + chmodSync(path, 0o644) + } +} + +// ── Shell launch config ───────────────────────────────────────────── + +export type ShellReadyLaunchConfig = { + args: string[] | null + env: Record + supportsReadyMarker: boolean +} + +export function getShellReadyLaunchConfig(shellPath: string): ShellReadyLaunchConfig { + const shellName = basename(shellPath).toLowerCase() + + if (shellName === 'zsh') { + ensureShellReadyWrappers() + return { + args: ['-l'], + env: { + ORCA_ORIG_ZDOTDIR: process.env.ZDOTDIR || process.env.HOME || '', + ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh` + }, + supportsReadyMarker: true + } + } + + if (shellName === 'bash') { + ensureShellReadyWrappers() + return { + args: ['--rcfile', `${getShellReadyWrapperRoot()}/bash/rcfile`], + env: {}, + supportsReadyMarker: true + } + } + + return { + args: null, + env: {}, + supportsReadyMarker: false + } +} + +// ── Startup command writer ────────────────────────────────────────── + +export function writeStartupCommandWhenShellReady( + readyPromise: Promise, + proc: pty.IPty, + startupCommand: string, + onExit: (cleanup: () => void) => void +): void { + let sent = false + + const cleanup = (): void => { + sent = true + } + + const flush = (): void => { + if (sent) { + return + } + sent = true + // Why: run startup commands inside the same interactive shell Orca keeps + // open for the pane. Spawning `shell -c ; exec shell -l` would + // avoid the race, but it would also replace the session after the agent + // exits and break "stay in this terminal" workflows. + const payload = startupCommand.endsWith('\n') ? startupCommand : `${startupCommand}\n` + // Why: startup commands are usually long, quoted agent launches. Writing + // them in one PTY call after the shell-ready barrier avoids the incremental + // paste behavior that still dropped characters in practice. + proc.write(payload) + } + + readyPromise.then(flush) + onExit(cleanup) +} + +export { STARTUP_COMMAND_READY_MAX_WAIT_MS } diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts new file mode 100644 index 00000000..be83a143 --- /dev/null +++ b/src/main/providers/local-pty-utils.ts @@ -0,0 +1,168 @@ +import { basename } from 'path' +import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'fs' +import type * as pty from 'node-pty' + +let didEnsureSpawnHelperExecutable = false + +/** + * Validate that a shell binary exists and is executable. + * Returns an error message string if invalid, null if valid. + */ +export function getShellValidationError(shellPath: string): string | null { + if (!existsSync(shellPath)) { + return ( + `Shell "${shellPath}" does not exist. ` + + `Set a valid SHELL environment variable or install zsh/bash.` + ) + } + try { + accessSync(shellPath, fsConstants.X_OK) + } catch { + return `Shell "${shellPath}" is not executable. Check file permissions.` + } + return null +} + +/** + * Ensure the node-pty spawn-helper binary has the executable bit set. + * + * Why: when Electron packages the app via asar, the native spawn-helper + * binary may lose its +x permission. This function detects and repairs + * that so pty.spawn() does not fail with EACCES on first launch. + */ +export function ensureNodePtySpawnHelperExecutable(): void { + if (didEnsureSpawnHelperExecutable || process.platform === 'win32') { + return + } + didEnsureSpawnHelperExecutable = true + + try { + const unixTerminalPath = require.resolve('node-pty/lib/unixTerminal.js') + const packageRoot = + basename(unixTerminalPath) === 'unixTerminal.js' + ? unixTerminalPath.replace(/[/\\]lib[/\\]unixTerminal\.js$/, '') + : unixTerminalPath + const candidates = [ + `${packageRoot}/build/Release/spawn-helper`, + `${packageRoot}/build/Debug/spawn-helper`, + `${packageRoot}/prebuilds/${process.platform}-${process.arch}/spawn-helper` + ].map((candidate) => + candidate + .replace('app.asar/', 'app.asar.unpacked/') + .replace('node_modules.asar/', 'node_modules.asar.unpacked/') + ) + + for (const candidate of candidates) { + if (!existsSync(candidate)) { + continue + } + const mode = statSync(candidate).mode + if ((mode & 0o111) !== 0) { + return + } + chmodSync(candidate, mode | 0o755) + return + } + } catch (error) { + console.warn( + `[pty] Failed to ensure node-pty spawn-helper is executable: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +/** + * Validate that a working directory exists and is a directory. + * Throws a descriptive Error if not. + */ +export function validateWorkingDirectory(cwd: string): void { + if (!existsSync(cwd)) { + throw new Error( + `Working directory "${cwd}" does not exist. ` + + `It may have been deleted or is on an unmounted volume.` + ) + } + if (!statSync(cwd).isDirectory()) { + throw new Error(`Working directory "${cwd}" is not a directory.`) + } +} + +export type ShellSpawnParams = { + shellPath: string + shellArgs: string[] + cols: number + rows: number + cwd: string + env: Record + ptySpawn: typeof pty.spawn + getShellReadyConfig?: ( + shell: string + ) => { args: string[] | null; env: Record } | null +} + +export type ShellSpawnResult = { + process: pty.IPty + shellPath: string +} + +/** + * Attempt to spawn a PTY shell. If the primary shell fails on Unix, + * try common fallback shells before giving up. + */ +export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResult { + const { shellPath, shellArgs, cols, rows, cwd, env, ptySpawn, getShellReadyConfig } = params + let primaryError: string | null = null + + if (process.platform !== 'win32') { + primaryError = getShellValidationError(shellPath) + } + + if (!primaryError) { + try { + return { + process: ptySpawn(shellPath, shellArgs, { name: 'xterm-256color', cols, rows, cwd, env }), + shellPath + } + } catch (err) { + primaryError = err instanceof Error ? err.message : String(err) + } + } + + // Try fallback shells on Unix + if (process.platform !== 'win32') { + const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter((s) => s !== shellPath) + for (const fallback of fallbackShells) { + if (getShellValidationError(fallback)) { + continue + } + try { + const fallbackReady = getShellReadyConfig?.(fallback) + env.SHELL = fallback + Object.assign(env, fallbackReady?.env ?? {}) + const proc = ptySpawn(fallback, fallbackReady?.args ?? ['-l'], { + name: 'xterm-256color', + cols, + rows, + cwd, + env + }) + console.warn( + `[pty] Primary shell "${shellPath}" failed (${primaryError ?? 'unknown error'}), fell back to "${fallback}"` + ) + return { process: proc, shellPath: fallback } + } catch { + // Fallback also failed -- try next. + } + } + } + + const diag = [ + `shell: ${shellPath}`, + `cwd: ${cwd}`, + `arch: ${process.arch}`, + `platform: ${process.platform} ${process.getSystemVersion?.() ?? ''}` + ].join(', ') + throw new Error( + `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ` + + `If this persists, please file an issue.` + ) +} diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts new file mode 100644 index 00000000..b2f1acb0 --- /dev/null +++ b/src/main/providers/provider-dispatch.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest' + +const { handleMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + removeAllListenersMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock, + on: vi.fn(), + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + } +})) + +vi.mock('fs', () => ({ + existsSync: () => true, + statSync: () => ({ isDirectory: () => true, mode: 0o755 }), + accessSync: () => undefined, + chmodSync: vi.fn(), + constants: { X_OK: 1 } +})) + +vi.mock('node-pty', () => ({ + spawn: vi.fn().mockReturnValue({ + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + process: 'zsh', + pid: 12345 + }) +})) + +vi.mock('../opencode/hook-service', () => ({ + openCodeHookService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } +})) + +vi.mock('../pi/titlebar-extension-service', () => ({ + piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } +})) + +import { registerPtyHandlers, registerSshPtyProvider, unregisterSshPtyProvider } from '../ipc/pty' +import type { IPtyProvider } from './types' + +describe('PTY provider dispatch', () => { + const handlers = new Map unknown>() + const mainWindow = { + isDestroyed: () => false, + webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } + } + + function setup(): void { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + registerPtyHandlers(mainWindow as never) + } + + it('routes to local provider when connectionId is null', async () => { + setup() + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + connectionId: null + })) as { id: string } + expect(result.id).toBeTruthy() + }) + + it('routes to local provider when connectionId is undefined', async () => { + setup() + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + expect(result.id).toBeTruthy() + }) + + it('routes to SSH provider when connectionId is set', async () => { + setup() + const mockSshProvider: IPtyProvider = { + spawn: vi.fn().mockResolvedValue({ id: 'ssh-pty-1' }), + attach: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + listProcesses: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn(), + onData: vi.fn().mockReturnValue(() => {}), + onReplay: vi.fn().mockReturnValue(() => {}), + onExit: vi.fn().mockReturnValue(() => {}) + } + + registerSshPtyProvider('conn-123', mockSshProvider) + + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + connectionId: 'conn-123' + })) as { id: string } + + expect(result.id).toBe('ssh-pty-1') + expect(mockSshProvider.spawn).toHaveBeenCalledWith({ + cols: 80, + rows: 24, + cwd: undefined, + env: undefined + }) + + unregisterSshPtyProvider('conn-123') + }) + + it('throws for unknown connectionId', async () => { + setup() + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + connectionId: 'unknown-conn' + }) + ).rejects.toThrow('No PTY provider for connection "unknown-conn"') + }) + + it('unregisterSshPtyProvider removes the provider', async () => { + setup() + const mockProvider: IPtyProvider = { + spawn: vi.fn().mockResolvedValue({ id: 'ssh-pty-2' }), + attach: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + listProcesses: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn(), + onData: vi.fn().mockReturnValue(() => {}), + onReplay: vi.fn().mockReturnValue(() => {}), + onExit: vi.fn().mockReturnValue(() => {}) + } + + registerSshPtyProvider('conn-456', mockProvider) + unregisterSshPtyProvider('conn-456') + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + connectionId: 'conn-456' + }) + ).rejects.toThrow('No PTY provider for connection "conn-456"') + }) +}) diff --git a/src/main/providers/ssh-filesystem-dispatch.ts b/src/main/providers/ssh-filesystem-dispatch.ts new file mode 100644 index 00000000..aa70df41 --- /dev/null +++ b/src/main/providers/ssh-filesystem-dispatch.ts @@ -0,0 +1,18 @@ +import type { IFilesystemProvider } from './types' + +const sshProviders = new Map() + +export function registerSshFilesystemProvider( + connectionId: string, + provider: IFilesystemProvider +): void { + sshProviders.set(connectionId, provider) +} + +export function unregisterSshFilesystemProvider(connectionId: string): void { + sshProviders.delete(connectionId) +} + +export function getSshFilesystemProvider(connectionId: string): IFilesystemProvider | undefined { + return sshProviders.get(connectionId) +} diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts new file mode 100644 index 00000000..eaa8c6e1 --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { SshFilesystemProvider } from './ssh-filesystem-provider' + +type MockMultiplexer = { + request: ReturnType + notify: ReturnType + onNotification: ReturnType + dispose: ReturnType + isDisposed: ReturnType +} + +function createMockMux(): MockMultiplexer { + return { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } +} + +describe('SshFilesystemProvider', () => { + let mux: MockMultiplexer + let provider: SshFilesystemProvider + + beforeEach(() => { + mux = createMockMux() + provider = new SshFilesystemProvider('conn-1', mux as never) + }) + + it('returns the connectionId', () => { + expect(provider.getConnectionId()).toBe('conn-1') + }) + + describe('readDir', () => { + it('sends fs.readDir request', async () => { + const entries = [ + { name: 'src', isDirectory: true, isSymlink: false }, + { name: 'README.md', isDirectory: false, isSymlink: false } + ] + mux.request.mockResolvedValue(entries) + + const result = await provider.readDir('/home/user/project') + expect(mux.request).toHaveBeenCalledWith('fs.readDir', { dirPath: '/home/user/project' }) + expect(result).toEqual(entries) + }) + }) + + describe('readFile', () => { + it('sends fs.readFile request', async () => { + const fileResult = { content: 'hello world', isBinary: false } + mux.request.mockResolvedValue(fileResult) + + const result = await provider.readFile('/home/user/file.txt') + expect(mux.request).toHaveBeenCalledWith('fs.readFile', { filePath: '/home/user/file.txt' }) + expect(result).toEqual(fileResult) + }) + }) + + describe('writeFile', () => { + it('sends fs.writeFile request', async () => { + await provider.writeFile('/home/user/file.txt', 'new content') + expect(mux.request).toHaveBeenCalledWith('fs.writeFile', { + filePath: '/home/user/file.txt', + content: 'new content' + }) + }) + }) + + describe('stat', () => { + it('sends fs.stat request', async () => { + const statResult = { size: 1024, type: 'file', mtime: 1234567890 } + mux.request.mockResolvedValue(statResult) + + const result = await provider.stat('/home/user/file.txt') + expect(mux.request).toHaveBeenCalledWith('fs.stat', { filePath: '/home/user/file.txt' }) + expect(result).toEqual(statResult) + }) + }) + + it('deletePath sends fs.deletePath request', async () => { + await provider.deletePath('/home/user/file.txt') + expect(mux.request).toHaveBeenCalledWith('fs.deletePath', { targetPath: '/home/user/file.txt' }) + }) + + it('createFile sends fs.createFile request', async () => { + await provider.createFile('/home/user/new.txt') + expect(mux.request).toHaveBeenCalledWith('fs.createFile', { filePath: '/home/user/new.txt' }) + }) + + it('createDir sends fs.createDir request', async () => { + await provider.createDir('/home/user/newdir') + expect(mux.request).toHaveBeenCalledWith('fs.createDir', { dirPath: '/home/user/newdir' }) + }) + + it('rename sends fs.rename request', async () => { + await provider.rename('/home/old.txt', '/home/new.txt') + expect(mux.request).toHaveBeenCalledWith('fs.rename', { + oldPath: '/home/old.txt', + newPath: '/home/new.txt' + }) + }) + + it('copy sends fs.copy request', async () => { + await provider.copy('/home/src.txt', '/home/dst.txt') + expect(mux.request).toHaveBeenCalledWith('fs.copy', { + source: '/home/src.txt', + destination: '/home/dst.txt' + }) + }) + + it('realpath sends fs.realpath request', async () => { + mux.request.mockResolvedValue('/home/user/real/path') + const result = await provider.realpath('/home/user/link') + expect(result).toBe('/home/user/real/path') + }) + + it('search sends fs.search request with all options', async () => { + const searchResult = { files: [], totalMatches: 0, truncated: false } + mux.request.mockResolvedValue(searchResult) + + const opts = { + query: 'TODO', + rootPath: '/home/user/project', + caseSensitive: true + } + const result = await provider.search(opts) + expect(mux.request).toHaveBeenCalledWith('fs.search', opts) + expect(result).toEqual(searchResult) + }) + + it('listFiles sends fs.listFiles request', async () => { + mux.request.mockResolvedValue(['src/index.ts', 'package.json']) + const result = await provider.listFiles('/home/user/project') + expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { rootPath: '/home/user/project' }) + expect(result).toEqual(['src/index.ts', 'package.json']) + }) + + describe('watch', () => { + it('sends fs.watch request and returns unsubscribe', async () => { + const callback = vi.fn() + const unsub = await provider.watch('/home/user/project', callback) + + expect(mux.request).toHaveBeenCalledWith('fs.watch', { rootPath: '/home/user/project' }) + expect(typeof unsub).toBe('function') + }) + + it('forwards fs.changed notifications to watch callback', async () => { + const callback = vi.fn() + await provider.watch('/home/user/project', callback) + + const notifHandler = mux.onNotification.mock.calls[0][0] + const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + notifHandler('fs.changed', { events }) + + expect(callback).toHaveBeenCalledWith(events) + }) + + it('sends fs.unwatch when last listener unsubscribes', async () => { + const callback = vi.fn() + const unsub = await provider.watch('/home/user/project', callback) + unsub() + + expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + }) + + it('does not send fs.unwatch while other roots are watched', async () => { + const cb1 = vi.fn() + const cb2 = vi.fn() + const unsub1 = await provider.watch('/home/user/project-a', cb1) + await provider.watch('/home/user/project-b', cb2) + + unsub1() + expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { + rootPath: '/home/user/project-b' + }) + }) + }) +}) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts new file mode 100644 index 00000000..62753d05 --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -0,0 +1,106 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { IFilesystemProvider, FileStat, FileReadResult } from './types' +import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' + +export class SshFilesystemProvider implements IFilesystemProvider { + private connectionId: string + private mux: SshChannelMultiplexer + // 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>() + // 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. + private unsubscribeNotifications: (() => void) | null = null + + constructor(connectionId: string, mux: SshChannelMultiplexer) { + 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)) + if (matching.length > 0) { + cb(matching) + } + } + } + }) + } + + dispose(): void { + if (this.unsubscribeNotifications) { + this.unsubscribeNotifications() + this.unsubscribeNotifications = null + } + this.watchListeners.clear() + } + + getConnectionId(): string { + return this.connectionId + } + + async readDir(dirPath: string): Promise { + return (await this.mux.request('fs.readDir', { dirPath })) as DirEntry[] + } + + async readFile(filePath: string): Promise { + return (await this.mux.request('fs.readFile', { filePath })) as FileReadResult + } + + async writeFile(filePath: string, content: string): Promise { + await this.mux.request('fs.writeFile', { filePath, content }) + } + + async stat(filePath: string): Promise { + return (await this.mux.request('fs.stat', { filePath })) as FileStat + } + + async deletePath(targetPath: string, recursive?: boolean): Promise { + await this.mux.request('fs.deletePath', { targetPath, recursive }) + } + + async createFile(filePath: string): Promise { + await this.mux.request('fs.createFile', { filePath }) + } + + async createDir(dirPath: string): Promise { + await this.mux.request('fs.createDir', { dirPath }) + } + + async rename(oldPath: string, newPath: string): Promise { + await this.mux.request('fs.rename', { oldPath, newPath }) + } + + async copy(source: string, destination: string): Promise { + await this.mux.request('fs.copy', { source, destination }) + } + + async realpath(filePath: string): Promise { + return (await this.mux.request('fs.realpath', { filePath })) as string + } + + async search(opts: SearchOptions): Promise { + return (await this.mux.request('fs.search', opts)) as SearchResult + } + + async listFiles(rootPath: string): Promise { + return (await this.mux.request('fs.listFiles', { rootPath })) as string[] + } + + async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> { + this.watchListeners.set(rootPath, callback) + await this.mux.request('fs.watch', { rootPath }) + + 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 }) + } + } +} diff --git a/src/main/providers/ssh-git-dispatch.ts b/src/main/providers/ssh-git-dispatch.ts new file mode 100644 index 00000000..b4e462a3 --- /dev/null +++ b/src/main/providers/ssh-git-dispatch.ts @@ -0,0 +1,15 @@ +import type { IGitProvider } from './types' + +const sshProviders = new Map() + +export function registerSshGitProvider(connectionId: string, provider: IGitProvider): void { + sshProviders.set(connectionId, provider) +} + +export function unregisterSshGitProvider(connectionId: string): void { + sshProviders.delete(connectionId) +} + +export function getSshGitProvider(connectionId: string): IGitProvider | undefined { + return sshProviders.get(connectionId) +} diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts new file mode 100644 index 00000000..3ccfbb11 --- /dev/null +++ b/src/main/providers/ssh-git-provider.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { SshGitProvider } from './ssh-git-provider' + +type MockMultiplexer = { + request: ReturnType + notify: ReturnType + onNotification: ReturnType + dispose: ReturnType + isDisposed: ReturnType +} + +function createMockMux(): MockMultiplexer { + return { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } +} + +describe('SshGitProvider', () => { + let mux: MockMultiplexer + let provider: SshGitProvider + + beforeEach(() => { + mux = createMockMux() + provider = new SshGitProvider('conn-1', mux as never) + }) + + it('returns the connectionId', () => { + expect(provider.getConnectionId()).toBe('conn-1') + }) + + it('getStatus sends git.status request', async () => { + const statusResult = { entries: [], conflictOperation: 'unknown' } + mux.request.mockResolvedValue(statusResult) + + const result = await provider.getStatus('/home/user/repo') + expect(mux.request).toHaveBeenCalledWith('git.status', { worktreePath: '/home/user/repo' }) + expect(result).toEqual(statusResult) + }) + + it('getDiff sends git.diff request', async () => { + const diffResult = { kind: 'text', originalContent: '', modifiedContent: 'hello' } + mux.request.mockResolvedValue(diffResult) + + const result = await provider.getDiff('/home/user/repo', 'src/index.ts', true) + expect(mux.request).toHaveBeenCalledWith('git.diff', { + worktreePath: '/home/user/repo', + filePath: 'src/index.ts', + staged: true + }) + expect(result).toEqual(diffResult) + }) + + it('stageFile sends git.stage request', async () => { + await provider.stageFile('/home/user/repo', 'src/file.ts') + expect(mux.request).toHaveBeenCalledWith('git.stage', { + worktreePath: '/home/user/repo', + filePath: 'src/file.ts' + }) + }) + + it('unstageFile sends git.unstage request', async () => { + await provider.unstageFile('/home/user/repo', 'src/file.ts') + expect(mux.request).toHaveBeenCalledWith('git.unstage', { + worktreePath: '/home/user/repo', + filePath: 'src/file.ts' + }) + }) + + it('bulkStageFiles sends git.bulkStage request', async () => { + await provider.bulkStageFiles('/home/user/repo', ['a.ts', 'b.ts']) + expect(mux.request).toHaveBeenCalledWith('git.bulkStage', { + worktreePath: '/home/user/repo', + filePaths: ['a.ts', 'b.ts'] + }) + }) + + it('bulkUnstageFiles sends git.bulkUnstage request', async () => { + await provider.bulkUnstageFiles('/home/user/repo', ['a.ts', 'b.ts']) + expect(mux.request).toHaveBeenCalledWith('git.bulkUnstage', { + worktreePath: '/home/user/repo', + filePaths: ['a.ts', 'b.ts'] + }) + }) + + it('discardChanges sends git.discard request', async () => { + await provider.discardChanges('/home/user/repo', 'src/file.ts') + expect(mux.request).toHaveBeenCalledWith('git.discard', { + worktreePath: '/home/user/repo', + filePath: 'src/file.ts' + }) + }) + + it('detectConflictOperation sends git.conflictOperation request', async () => { + mux.request.mockResolvedValue('rebase') + const result = await provider.detectConflictOperation('/home/user/repo') + expect(mux.request).toHaveBeenCalledWith('git.conflictOperation', { + worktreePath: '/home/user/repo' + }) + expect(result).toBe('rebase') + }) + + it('getBranchCompare sends git.branchCompare request', async () => { + const compareResult = { summary: { ahead: 2, behind: 0 }, entries: [] } + mux.request.mockResolvedValue(compareResult) + + const result = await provider.getBranchCompare('/home/user/repo', 'main') + expect(mux.request).toHaveBeenCalledWith('git.branchCompare', { + worktreePath: '/home/user/repo', + baseRef: 'main' + }) + expect(result).toEqual(compareResult) + }) + + it('getBranchDiff sends git.branchDiff request', async () => { + const diffs = [{ kind: 'text', originalContent: '', modifiedContent: 'new' }] + mux.request.mockResolvedValue(diffs) + + const result = await provider.getBranchDiff('/home/user/repo', 'main') + expect(mux.request).toHaveBeenCalledWith('git.branchDiff', { + worktreePath: '/home/user/repo', + baseRef: 'main' + }) + expect(result).toEqual(diffs) + }) + + it('listWorktrees sends git.listWorktrees request', async () => { + const worktrees = [ + { + path: '/home/user/repo', + head: 'abc123', + branch: 'main', + isBare: false, + isMainWorktree: true + } + ] + mux.request.mockResolvedValue(worktrees) + + const result = await provider.listWorktrees('/home/user/repo') + expect(mux.request).toHaveBeenCalledWith('git.listWorktrees', { repoPath: '/home/user/repo' }) + expect(result).toEqual(worktrees) + }) + + it('addWorktree sends git.addWorktree request', async () => { + await provider.addWorktree('/home/user/repo', 'feature', '/home/user/feat', { base: 'main' }) + expect(mux.request).toHaveBeenCalledWith('git.addWorktree', { + repoPath: '/home/user/repo', + branchName: 'feature', + targetDir: '/home/user/feat', + base: 'main' + }) + }) + + it('removeWorktree sends git.removeWorktree request', async () => { + await provider.removeWorktree('/home/user/feat', true) + expect(mux.request).toHaveBeenCalledWith('git.removeWorktree', { + worktreePath: '/home/user/feat', + force: true + }) + }) + + it('isGitRepo always returns true for remote paths', () => { + expect(provider.isGitRepo('/any/path')).toBe(true) + }) +}) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts new file mode 100644 index 00000000..1d91fc56 --- /dev/null +++ b/src/main/providers/ssh-git-provider.ts @@ -0,0 +1,175 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { IGitProvider } from './types' +import hostedGitInfo from 'hosted-git-info' +import type { + GitStatusResult, + GitDiffResult, + GitBranchCompareResult, + GitConflictOperation, + GitWorktreeInfo +} from '../../shared/types' + +export class SshGitProvider implements IGitProvider { + private connectionId: string + private mux: SshChannelMultiplexer + + constructor(connectionId: string, mux: SshChannelMultiplexer) { + this.connectionId = connectionId + this.mux = mux + } + + getConnectionId(): string { + return this.connectionId + } + + async getStatus(worktreePath: string): Promise { + return (await this.mux.request('git.status', { worktreePath })) as GitStatusResult + } + + async getDiff(worktreePath: string, filePath: string, staged: boolean): Promise { + return (await this.mux.request('git.diff', { + worktreePath, + filePath, + staged + })) as GitDiffResult + } + + async stageFile(worktreePath: string, filePath: string): Promise { + await this.mux.request('git.stage', { worktreePath, filePath }) + } + + async unstageFile(worktreePath: string, filePath: string): Promise { + await this.mux.request('git.unstage', { worktreePath, filePath }) + } + + async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.mux.request('git.bulkStage', { worktreePath, filePaths }) + } + + async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) + } + + async discardChanges(worktreePath: string, filePath: string): Promise { + await this.mux.request('git.discard', { worktreePath, filePath }) + } + + async detectConflictOperation(worktreePath: string): Promise { + return (await this.mux.request('git.conflictOperation', { + worktreePath + })) as GitConflictOperation + } + + async getBranchCompare(worktreePath: string, baseRef: string): Promise { + return (await this.mux.request('git.branchCompare', { + worktreePath, + baseRef + })) as GitBranchCompareResult + } + + async getBranchDiff( + worktreePath: string, + baseRef: string, + options?: { includePatch?: boolean; filePath?: string; oldPath?: string } + ): Promise { + return (await this.mux.request('git.branchDiff', { + worktreePath, + baseRef, + ...options + })) as GitDiffResult[] + } + + async listWorktrees(repoPath: string): Promise { + return (await this.mux.request('git.listWorktrees', { + repoPath + })) as GitWorktreeInfo[] + } + + async addWorktree( + repoPath: string, + branchName: string, + targetDir: string, + options?: { base?: string; track?: boolean } + ): Promise { + await this.mux.request('git.addWorktree', { + repoPath, + branchName, + targetDir, + ...options + }) + } + + async removeWorktree(worktreePath: string, force?: boolean): Promise { + await this.mux.request('git.removeWorktree', { worktreePath, force }) + } + + async exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> { + return (await this.mux.request('git.exec', { args, cwd })) as { + stdout: string + stderr: string + } + } + + async isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> { + return (await this.mux.request('git.isGitRepo', { dirPath })) as { + isRepo: boolean + rootPath: string | null + } + } + + // Why: isGitRepo requires synchronous return in the interface, but remote + // operations are async. We always return true for remote paths since the + // relay validates git repos on its side. The renderer already guards git + // operations behind worktree registration which validates the path. + isGitRepo(_path: string): boolean { + return true + } + + // Why: the local getRemoteFileUrl uses hosted-git-info which requires the + // remote URL from .git/config. For SSH connections we must fetch the remote + // URL from the relay, then apply the same hosted-git-info logic locally. + async getRemoteFileUrl( + worktreePath: string, + relativePath: string, + line: number + ): Promise { + let remoteUrl: string + try { + const result = await this.exec(['remote', 'get-url', 'origin'], worktreePath) + remoteUrl = result.stdout.trim() + } catch { + return null + } + if (!remoteUrl) { + return null + } + + const info = hostedGitInfo.fromUrl(remoteUrl) + if (!info) { + return null + } + + let defaultBranch = 'main' + try { + const refResult = await this.exec( + ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + worktreePath + ) + const ref = refResult.stdout.trim() + if (ref) { + defaultBranch = ref.replace(/^refs\/remotes\/origin\//, '') + } + } catch { + // Fall back to 'main' + } + + const browseUrl = info.browseFile(relativePath, { committish: defaultBranch }) + if (!browseUrl) { + return null + } + + // Why: hosted-git-info lowercases the fragment, but GitHub convention + // uses uppercase L for line links (e.g. #L42). Append manually. + return `${browseUrl}#L${line}` + } +} diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts new file mode 100644 index 00000000..163c8624 --- /dev/null +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { SshPtyProvider } from './ssh-pty-provider' + +type MockMultiplexer = { + request: ReturnType + notify: ReturnType + onNotification: ReturnType + dispose: ReturnType + isDisposed: ReturnType +} + +function createMockMux(): MockMultiplexer { + return { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } +} + +describe('SshPtyProvider', () => { + let mux: MockMultiplexer + let provider: SshPtyProvider + + beforeEach(() => { + mux = createMockMux() + provider = new SshPtyProvider('conn-1', mux as never) + }) + + it('returns the connectionId', () => { + expect(provider.getConnectionId()).toBe('conn-1') + }) + + describe('spawn', () => { + it('sends pty.spawn request through multiplexer', async () => { + mux.request.mockResolvedValue({ id: 'pty-1' }) + + const result = await provider.spawn({ cols: 80, rows: 24 }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 80, + rows: 24, + cwd: undefined, + env: undefined + }) + expect(result).toEqual({ id: 'pty-1' }) + }) + + it('passes cwd and env through', async () => { + mux.request.mockResolvedValue({ id: 'pty-2' }) + + await provider.spawn({ + cols: 120, + rows: 40, + cwd: '/home/user', + env: { FOO: 'bar' } + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: '/home/user', + env: { FOO: 'bar' } + }) + }) + }) + + it('attach sends pty.attach request', async () => { + await provider.attach('pty-1') + expect(mux.request).toHaveBeenCalledWith('pty.attach', { id: 'pty-1' }) + }) + + it('write sends pty.data notification', () => { + provider.write('pty-1', 'hello') + expect(mux.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello' }) + }) + + it('resize sends pty.resize notification', () => { + provider.resize('pty-1', 120, 40) + expect(mux.notify).toHaveBeenCalledWith('pty.resize', { id: 'pty-1', cols: 120, rows: 40 }) + }) + + it('shutdown sends pty.shutdown request', async () => { + await provider.shutdown('pty-1', true) + expect(mux.request).toHaveBeenCalledWith('pty.shutdown', { id: 'pty-1', immediate: true }) + }) + + it('sendSignal sends pty.sendSignal request', async () => { + await provider.sendSignal('pty-1', 'SIGINT') + expect(mux.request).toHaveBeenCalledWith('pty.sendSignal', { id: 'pty-1', signal: 'SIGINT' }) + }) + + it('getCwd sends pty.getCwd request', async () => { + mux.request.mockResolvedValue('/home/user/project') + const cwd = await provider.getCwd('pty-1') + expect(cwd).toBe('/home/user/project') + }) + + it('clearBuffer sends pty.clearBuffer request', async () => { + await provider.clearBuffer('pty-1') + expect(mux.request).toHaveBeenCalledWith('pty.clearBuffer', { id: 'pty-1' }) + }) + + it('acknowledgeDataEvent sends pty.ackData notification', () => { + provider.acknowledgeDataEvent('pty-1', 1024) + expect(mux.notify).toHaveBeenCalledWith('pty.ackData', { id: 'pty-1', charCount: 1024 }) + }) + + it('hasChildProcesses sends request and returns result', async () => { + mux.request.mockResolvedValue(true) + const result = await provider.hasChildProcesses('pty-1') + expect(result).toBe(true) + }) + + it('getForegroundProcess returns process name', async () => { + mux.request.mockResolvedValue('node') + const result = await provider.getForegroundProcess('pty-1') + expect(result).toBe('node') + }) + + it('listProcesses returns process list', async () => { + const processes = [{ id: 'pty-1', cwd: '/home', title: 'zsh' }] + mux.request.mockResolvedValue(processes) + const result = await provider.listProcesses() + expect(result).toEqual(processes) + }) + + it('getDefaultShell returns shell path', async () => { + mux.request.mockResolvedValue('/bin/bash') + const result = await provider.getDefaultShell() + expect(result).toBe('/bin/bash') + }) + + describe('event listeners', () => { + it('forwards pty.data notifications to data listeners', () => { + const handler = vi.fn() + provider.onData(handler) + + // Get the notification handler that was registered + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('pty.data', { id: 'pty-1', data: 'output' }) + + expect(handler).toHaveBeenCalledWith({ id: 'pty-1', data: 'output' }) + }) + + it('forwards pty.replay notifications to replay listeners', () => { + const handler = vi.fn() + provider.onReplay(handler) + + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('pty.replay', { id: 'pty-1', data: 'buffered output' }) + + expect(handler).toHaveBeenCalledWith({ id: 'pty-1', data: 'buffered output' }) + }) + + it('forwards pty.exit notifications to exit listeners', () => { + const handler = vi.fn() + provider.onExit(handler) + + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('pty.exit', { id: 'pty-1', code: 0 }) + + expect(handler).toHaveBeenCalledWith({ id: 'pty-1', code: 0 }) + }) + + it('allows unsubscribing from events', () => { + const handler = vi.fn() + const unsub = provider.onData(handler) + unsub() + + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('pty.data', { id: 'pty-1', data: 'output' }) + + expect(handler).not.toHaveBeenCalled() + }) + + it('supports multiple listeners', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + provider.onData(handler1) + provider.onData(handler2) + + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('pty.data', { id: 'pty-1', data: 'output' }) + + expect(handler1).toHaveBeenCalled() + expect(handler2).toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts new file mode 100644 index 00000000..53a4d628 --- /dev/null +++ b/src/main/providers/ssh-pty-provider.ts @@ -0,0 +1,162 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' + +type DataCallback = (payload: { id: string; data: string }) => void +type ReplayCallback = (payload: { id: string; data: string }) => void +type ExitCallback = (payload: { id: string; code: number }) => void + +/** + * Remote PTY provider that proxies all operations through the relay + * via the JSON-RPC multiplexer. Implements the same IPtyProvider interface + * as LocalPtyProvider so the dispatch layer can route transparently. + */ +export class SshPtyProvider implements IPtyProvider { + private mux: SshChannelMultiplexer + private connectionId: string + private dataListeners = new Set() + private replayListeners = new Set() + private exitListeners = new Set() + // 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. + private unsubscribeNotifications: (() => void) | null = null + + constructor(connectionId: string, mux: SshChannelMultiplexer) { + this.connectionId = connectionId + this.mux = mux + + // Subscribe to relay notifications for PTY events + this.unsubscribeNotifications = mux.onNotification((method, params) => { + switch (method) { + case 'pty.data': + for (const cb of this.dataListeners) { + cb({ id: params.id as string, data: params.data as string }) + } + break + + case 'pty.replay': + for (const cb of this.replayListeners) { + cb({ id: params.id as string, data: params.data as string }) + } + break + + case 'pty.exit': + for (const cb of this.exitListeners) { + cb({ id: params.id as string, code: params.code as number }) + } + break + } + }) + } + + dispose(): void { + if (this.unsubscribeNotifications) { + this.unsubscribeNotifications() + this.unsubscribeNotifications = null + } + this.dataListeners.clear() + this.replayListeners.clear() + this.exitListeners.clear() + } + + getConnectionId(): string { + return this.connectionId + } + + async spawn(opts: PtySpawnOptions): Promise { + const result = await this.mux.request('pty.spawn', { + cols: opts.cols, + rows: opts.rows, + cwd: opts.cwd, + env: opts.env + }) + return result as PtySpawnResult + } + + async attach(id: string): Promise { + await this.mux.request('pty.attach', { id }) + } + + write(id: string, data: string): void { + this.mux.notify('pty.data', { id, data }) + } + + resize(id: string, cols: number, rows: number): void { + this.mux.notify('pty.resize', { id, cols, rows }) + } + + async shutdown(id: string, immediate: boolean): Promise { + await this.mux.request('pty.shutdown', { id, immediate }) + } + + async sendSignal(id: string, signal: string): Promise { + await this.mux.request('pty.sendSignal', { id, signal }) + } + + async getCwd(id: string): Promise { + const result = await this.mux.request('pty.getCwd', { id }) + return result as string + } + + async getInitialCwd(id: string): Promise { + const result = await this.mux.request('pty.getInitialCwd', { id }) + return result as string + } + + async clearBuffer(id: string): Promise { + await this.mux.request('pty.clearBuffer', { id }) + } + + acknowledgeDataEvent(id: string, charCount: number): void { + this.mux.notify('pty.ackData', { id, charCount }) + } + + async hasChildProcesses(id: string): Promise { + const result = await this.mux.request('pty.hasChildProcesses', { id }) + return result as boolean + } + + async getForegroundProcess(id: string): Promise { + const result = await this.mux.request('pty.getForegroundProcess', { id }) + return result as string | null + } + + async serialize(ids: string[]): Promise { + const result = await this.mux.request('pty.serialize', { ids }) + return result as string + } + + async revive(state: string): Promise { + await this.mux.request('pty.revive', { state }) + } + + async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + const result = await this.mux.request('pty.listProcesses') + return result as { id: string; cwd: string; title: string }[] + } + + async getDefaultShell(): Promise { + const result = await this.mux.request('pty.getDefaultShell') + return result as string + } + + async getProfiles(): Promise<{ name: string; path: string }[]> { + const result = await this.mux.request('pty.getProfiles') + return result as { name: string; path: string }[] + } + + onData(callback: DataCallback): () => void { + this.dataListeners.add(callback) + return () => this.dataListeners.delete(callback) + } + + onReplay(callback: ReplayCallback): () => void { + this.replayListeners.add(callback) + return () => this.replayListeners.delete(callback) + } + + onExit(callback: ExitCallback): () => void { + this.exitListeners.add(callback) + return () => this.exitListeners.delete(callback) + } +} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts new file mode 100644 index 00000000..8650db1e --- /dev/null +++ b/src/main/providers/types.ts @@ -0,0 +1,122 @@ +import type { + DirEntry, + FsChangeEvent, + GitStatusResult, + GitDiffResult, + GitBranchCompareResult, + GitConflictOperation, + GitWorktreeInfo, + SearchOptions, + SearchResult +} from '../../shared/types' + +// ─── PTY Provider ─────────────────────────────────────────────────── + +export type PtySpawnOptions = { + cols: number + rows: number + cwd?: string + env?: Record + command?: string +} + +export type PtySpawnResult = { + id: string +} + +export type IPtyProvider = { + spawn(opts: PtySpawnOptions): Promise + attach(id: string): Promise + write(id: string, data: string): void + resize(id: string, cols: number, rows: number): void + shutdown(id: string, immediate: boolean): Promise + sendSignal(id: string, signal: string): Promise + getCwd(id: string): Promise + getInitialCwd(id: string): Promise + clearBuffer(id: string): Promise + acknowledgeDataEvent(id: string, charCount: number): void + hasChildProcesses(id: string): Promise + getForegroundProcess(id: string): Promise + serialize(ids: string[]): Promise + revive(state: string): Promise + listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> + getDefaultShell(): Promise + getProfiles(): Promise<{ name: string; path: string }[]> + onData(callback: (payload: { id: string; data: string }) => void): () => void + onReplay(callback: (payload: { id: string; data: string }) => void): () => void + onExit(callback: (payload: { id: string; code: number }) => void): () => void +} + +// ─── Filesystem Provider ──────────────────────────────────────────── + +export type FileStat = { + size: number + type: 'file' | 'directory' | 'symlink' + mtime: number +} + +export type FileReadResult = { + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string +} + +export type IFilesystemProvider = { + readDir(dirPath: string): Promise + readFile(filePath: string): Promise + writeFile(filePath: string, content: string): Promise + stat(filePath: string): Promise + deletePath(targetPath: string): Promise + createFile(filePath: string): Promise + createDir(dirPath: string): Promise + rename(oldPath: string, newPath: string): Promise + copy(source: string, destination: string): Promise + realpath(filePath: string): Promise + search(opts: SearchOptions): Promise + listFiles(rootPath: string): Promise + watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> +} + +// ─── Git Provider ─────────────────────────────────────────────────── + +export type IGitProvider = { + getStatus(worktreePath: string): Promise + getDiff(worktreePath: string, filePath: string, staged: boolean): Promise + stageFile(worktreePath: string, filePath: string): Promise + unstageFile(worktreePath: string, filePath: string): Promise + bulkStageFiles(worktreePath: string, filePaths: string[]): Promise + bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise + discardChanges(worktreePath: string, filePath: string): Promise + detectConflictOperation(worktreePath: string): Promise + getBranchCompare(worktreePath: string, baseRef: string): Promise + getBranchDiff( + worktreePath: string, + baseRef: string, + options?: { includePatch?: boolean; filePath?: string; oldPath?: string } + ): Promise + listWorktrees(repoPath: string): Promise + addWorktree( + repoPath: string, + branchName: string, + targetDir: string, + options?: { base?: string; track?: boolean } + ): Promise + removeWorktree(worktreePath: string, force?: boolean): Promise + isGitRepo(path: string): boolean + isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> + exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> + getRemoteFileUrl(worktreePath: string, relativePath: string, line: number): Promise +} + +// ─── Provider Registry ────────────────────────────────────────────── + +/** + * Routes operations to the correct provider based on connectionId. + * null/undefined connectionId = local provider. + */ +export type IProviderRegistry = { + getPtyProvider(connectionId: string | null | undefined): IPtyProvider + getFilesystemProvider(connectionId: string | null | undefined): IFilesystemProvider + getGitProvider(connectionId: string | null | undefined): IGitProvider +} diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts new file mode 100644 index 00000000..827b5a09 --- /dev/null +++ b/src/main/ssh/relay-protocol.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest' +import { + HEADER_LENGTH, + MessageType, + encodeFrame, + encodeJsonRpcFrame, + encodeKeepAliveFrame, + FrameDecoder, + parseJsonRpcMessage, + parseUnameToRelayPlatform, + type JsonRpcRequest, + type DecodedFrame +} from './relay-protocol' + +describe('frame encoding', () => { + it('encodes a frame with 13-byte header', () => { + const payload = Buffer.from('hello') + const frame = encodeFrame(MessageType.Regular, 1, 0, payload) + + expect(frame.length).toBe(HEADER_LENGTH + payload.length) + expect(frame[0]).toBe(MessageType.Regular) + expect(frame.readUInt32BE(1)).toBe(1) // ID + expect(frame.readUInt32BE(5)).toBe(0) // ACK + expect(frame.readUInt32BE(9)).toBe(5) // LENGTH + expect(frame.subarray(HEADER_LENGTH).toString()).toBe('hello') + }) + + it('encodes keepalive frame with empty payload', () => { + const frame = encodeKeepAliveFrame(42, 10) + + expect(frame.length).toBe(HEADER_LENGTH) + expect(frame[0]).toBe(MessageType.KeepAlive) + expect(frame.readUInt32BE(1)).toBe(42) // ID + expect(frame.readUInt32BE(5)).toBe(10) // ACK + expect(frame.readUInt32BE(9)).toBe(0) // LENGTH + }) + + it('encodes JSON-RPC frame', () => { + const msg: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: 'pty.spawn', + params: { cols: 80, rows: 24 } + } + const frame = encodeJsonRpcFrame(msg, 5, 3) + + expect(frame[0]).toBe(MessageType.Regular) + expect(frame.readUInt32BE(1)).toBe(5) + expect(frame.readUInt32BE(5)).toBe(3) + + const payloadLen = frame.readUInt32BE(9) + const payload = frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLen) + const decoded = JSON.parse(payload.toString('utf-8')) + expect(decoded.method).toBe('pty.spawn') + expect(decoded.params.cols).toBe(80) + }) + + it('rejects messages larger than MAX_MESSAGE_SIZE', () => { + const bigPayload = { + jsonrpc: '2.0' as const, + id: 1, + method: 'x', + params: { data: 'a'.repeat(17 * 1024 * 1024) } + } + expect(() => encodeJsonRpcFrame(bigPayload, 1, 0)).toThrow('Message too large') + }) +}) + +describe('FrameDecoder', () => { + it('decodes a complete frame', () => { + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + + const payload = Buffer.from('test') + const encoded = encodeFrame(MessageType.Regular, 1, 0, payload) + decoder.feed(encoded) + + expect(frames).toHaveLength(1) + expect(frames[0].type).toBe(MessageType.Regular) + expect(frames[0].id).toBe(1) + expect(frames[0].ack).toBe(0) + expect(frames[0].payload.toString()).toBe('test') + }) + + it('handles partial frames across multiple feeds', () => { + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + + const payload = Buffer.from('hello world') + const encoded = encodeFrame(MessageType.Regular, 2, 1, payload) + + // Feed in two parts + decoder.feed(encoded.subarray(0, 10)) + expect(frames).toHaveLength(0) + + decoder.feed(encoded.subarray(10)) + expect(frames).toHaveLength(1) + expect(frames[0].payload.toString()).toBe('hello world') + }) + + it('decodes multiple frames from a single chunk', () => { + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + + const frame1 = encodeFrame(MessageType.Regular, 1, 0, Buffer.from('a')) + const frame2 = encodeFrame(MessageType.Regular, 2, 1, Buffer.from('b')) + const combined = Buffer.concat([frame1, frame2]) + + decoder.feed(combined) + expect(frames).toHaveLength(2) + expect(frames[0].payload.toString()).toBe('a') + expect(frames[1].payload.toString()).toBe('b') + }) + + it('decodes keepalive frames', () => { + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + + decoder.feed(encodeKeepAliveFrame(5, 3)) + expect(frames).toHaveLength(1) + expect(frames[0].type).toBe(MessageType.KeepAlive) + expect(frames[0].payload.length).toBe(0) + }) + + it('skips oversized frames and calls onError instead of throwing', () => { + const errors: Error[] = [] + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder( + (f) => frames.push(f), + (err) => errors.push(err) + ) + + const oversizedLength = 17 * 1024 * 1024 + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = MessageType.Regular + header.writeUInt32BE(1, 1) + header.writeUInt32BE(0, 5) + header.writeUInt32BE(oversizedLength, 9) + + const fakePayload = Buffer.alloc(oversizedLength) + const fullFrame = Buffer.concat([header, fakePayload]) + + decoder.feed(fullFrame) + expect(frames).toHaveLength(0) + expect(errors).toHaveLength(1) + expect(errors[0].message).toContain('discarded') + }) + + it('reset clears internal buffer', () => { + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + + // Feed a partial frame + const encoded = encodeFrame(MessageType.Regular, 1, 0, Buffer.from('test')) + decoder.feed(encoded.subarray(0, 5)) + decoder.reset() + + // Feed a new complete frame + decoder.feed(encodeFrame(MessageType.Regular, 2, 0, Buffer.from('new'))) + expect(frames).toHaveLength(1) + expect(frames[0].id).toBe(2) + }) +}) + +describe('parseJsonRpcMessage', () => { + it('parses a valid JSON-RPC request', () => { + const payload = Buffer.from( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'pty.spawn', + params: { cols: 80 } + }) + ) + const msg = parseJsonRpcMessage(payload) + expect('method' in msg && msg.method).toBe('pty.spawn') + }) + + it('throws on invalid jsonrpc version', () => { + const payload = Buffer.from(JSON.stringify({ jsonrpc: '1.0', id: 1, method: 'x' })) + expect(() => parseJsonRpcMessage(payload)).toThrow('Invalid JSON-RPC version') + }) + + it('throws on malformed JSON', () => { + const payload = Buffer.from('not json') + expect(() => parseJsonRpcMessage(payload)).toThrow() + }) +}) + +describe('parseUnameToRelayPlatform', () => { + it('maps Linux x86_64', () => { + expect(parseUnameToRelayPlatform('Linux', 'x86_64')).toBe('linux-x64') + }) + + it('maps Linux aarch64', () => { + expect(parseUnameToRelayPlatform('Linux', 'aarch64')).toBe('linux-arm64') + }) + + it('maps Darwin x86_64', () => { + expect(parseUnameToRelayPlatform('Darwin', 'x86_64')).toBe('darwin-x64') + }) + + it('maps Darwin arm64', () => { + expect(parseUnameToRelayPlatform('Darwin', 'arm64')).toBe('darwin-arm64') + }) + + it('handles amd64 alias', () => { + expect(parseUnameToRelayPlatform('Linux', 'amd64')).toBe('linux-x64') + }) + + it('returns null for unsupported OS', () => { + expect(parseUnameToRelayPlatform('FreeBSD', 'x86_64')).toBeNull() + }) + + it('returns null for unsupported arch', () => { + expect(parseUnameToRelayPlatform('Linux', 'mips')).toBeNull() + }) + + it('is case-insensitive', () => { + expect(parseUnameToRelayPlatform('LINUX', 'X86_64')).toBe('linux-x64') + }) +}) diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts new file mode 100644 index 00000000..5a134fc7 --- /dev/null +++ b/src/main/ssh/relay-protocol.ts @@ -0,0 +1,205 @@ +// ─── Relay Protocol ───────────────────────────────────────────────── +// 13-byte framing header matching VS Code's PersistentProtocol wire format. +// See design-ssh-support.md § JSON-RPC Protocol Specification. + +export const RELAY_VERSION = '0.1.0' +export const RELAY_SENTINEL = `ORCA-RELAY v${RELAY_VERSION} READY\n` +export const RELAY_SENTINEL_TIMEOUT_MS = 10_000 +export const RELAY_REMOTE_DIR = '.orca-remote' + +// ── Framing constants (VS Code ProtocolConstants) ─────────────────── + +export const HEADER_LENGTH = 13 +export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024 // 16 MB + +/** Message type byte. */ +export const MessageType = { + Regular: 1, + KeepAlive: 9 +} as const + +/** Keepalive/timeout (VS Code ProtocolConstants). */ +export const KEEPALIVE_SEND_MS = 5_000 +export const TIMEOUT_MS = 20_000 + +/** PTY flow control watermarks (VS Code FlowControlConstants). */ +export const PTY_FLOW_HIGH_WATERMARK = 100_000 +export const PTY_FLOW_LOW_WATERMARK = 5_000 + +/** Reconnection grace period (default, overridable by relay --grace-time). */ +export const DEFAULT_GRACE_TIME_MS = 5 * 60 * 1000 // 5 minutes + +// ── Relay error codes ─────────────────────────────────────────────── + +export const RelayErrorCode = { + CommandNotFound: -33001, + PermissionDenied: -33002, + PathNotFound: -33003, + PtyAllocationFailed: -33004, + DiskFull: -33005 +} as const + +// ── JSON-RPC types ────────────────────────────────────────────────── + +export type JsonRpcRequest = { + jsonrpc: '2.0' + id: number + method: string + params?: Record +} + +export type JsonRpcResponse = { + jsonrpc: '2.0' + id: number + result?: unknown + error?: { code: number; message: string; data?: unknown } +} + +export type JsonRpcNotification = { + jsonrpc: '2.0' + method: string + params?: Record +} + +export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification + +// ── Framing: encode / decode ──────────────────────────────────────── + +/** + * Encode a message into a framed buffer (13-byte header + payload). + * + * Header layout: + * - [0]: TYPE (1 byte) + * - [1-4]: ID (uint32 big-endian) + * - [5-8]: ACK (uint32 big-endian) + * - [9-12]: LENGTH (uint32 big-endian) + */ +export function encodeFrame( + type: number, + id: number, + ack: number, + payload: Buffer | Uint8Array +): Buffer { + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = type + header.writeUInt32BE(id, 1) + header.writeUInt32BE(ack, 5) + header.writeUInt32BE(payload.length, 9) + return Buffer.concat([header, payload]) +} + +export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer { + const payload = Buffer.from(JSON.stringify(msg), 'utf-8') + if (payload.length > MAX_MESSAGE_SIZE) { + throw new Error(`Message too large: ${payload.length} bytes (max ${MAX_MESSAGE_SIZE})`) + } + return encodeFrame(MessageType.Regular, id, ack, payload) +} + +export function encodeKeepAliveFrame(id: number, ack: number): Buffer { + return encodeFrame(MessageType.KeepAlive, id, ack, Buffer.alloc(0)) +} + +export type DecodedFrame = { + type: number + id: number + ack: number + payload: Buffer +} + +/** + * Incremental frame parser. Feed it chunks of data; it emits complete frames. + */ +export class FrameDecoder { + private buffer = Buffer.alloc(0) + private onFrame: (frame: DecodedFrame) => void + private onError: ((err: Error) => void) | null + + constructor(onFrame: (frame: DecodedFrame) => void, onError?: (err: Error) => void) { + this.onFrame = onFrame + this.onError = onError ?? null + } + + feed(chunk: Buffer | Uint8Array): void { + this.buffer = Buffer.concat([this.buffer, chunk]) + + while (this.buffer.length >= HEADER_LENGTH) { + const length = this.buffer.readUInt32BE(9) + const totalLength = HEADER_LENGTH + length + + // Why: throwing here would leave the buffer in a partially consumed + // state — subsequent feed() calls would try to parse leftover payload + // bytes as a new header, corrupting every future frame. Instead we + // skip the entire oversized frame so the decoder stays synchronized. + if (length > MAX_MESSAGE_SIZE) { + if (this.buffer.length < totalLength) { + break + } + this.buffer = this.buffer.subarray(totalLength) + const err = new Error(`Frame payload too large: ${length} bytes — discarded`) + if (this.onError) { + this.onError(err) + } + continue + } + + if (this.buffer.length < totalLength) { + break + } + + const frame: DecodedFrame = { + type: this.buffer[0], + id: this.buffer.readUInt32BE(1), + ack: this.buffer.readUInt32BE(5), + payload: this.buffer.subarray(HEADER_LENGTH, totalLength) + } + + this.buffer = this.buffer.subarray(totalLength) + this.onFrame(frame) + } + } + + reset(): void { + this.buffer = Buffer.alloc(0) + } +} + +/** + * Parse a JSON-RPC message from a frame payload. + */ +export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { + const text = payload.toString('utf-8') + const msg = JSON.parse(text) as JsonRpcMessage + if (msg.jsonrpc !== '2.0') { + throw new Error(`Invalid JSON-RPC version: ${(msg as Record).jsonrpc}`) + } + return msg +} + +// ── Supported platforms ───────────────────────────────────────────── + +export type RelayPlatform = 'linux-x64' | 'linux-arm64' | 'darwin-x64' | 'darwin-arm64' + +export function parseUnameToRelayPlatform(os: string, arch: string): RelayPlatform | null { + const normalizedOs = os.toLowerCase().trim() + const normalizedArch = arch.toLowerCase().trim() + + let relayOs: string | null = null + if (normalizedOs === 'linux') { + relayOs = 'linux' + } else if (normalizedOs === 'darwin') { + relayOs = 'darwin' + } + + let relayArch: string | null = null + if (normalizedArch === 'x86_64' || normalizedArch === 'amd64') { + relayArch = 'x64' + } else if (normalizedArch === 'aarch64' || normalizedArch === 'arm64') { + relayArch = 'arm64' + } + + if (!relayOs || !relayArch) { + return null + } + return `${relayOs}-${relayArch}` as RelayPlatform +} diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts new file mode 100644 index 00000000..85350f25 --- /dev/null +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer' +import { encodeFrame, MessageType, HEADER_LENGTH, encodeKeepAliveFrame } from './relay-protocol' + +function createMockTransport(): MultiplexerTransport & { + dataCallbacks: ((data: Buffer) => void)[] + closeCallbacks: (() => void)[] + written: Buffer[] +} { + const dataCallbacks: ((data: Buffer) => void)[] = [] + const closeCallbacks: (() => void)[] = [] + const written: Buffer[] = [] + + return { + write: (data: Buffer) => written.push(data), + onData: (cb) => dataCallbacks.push(cb), + onClose: (cb) => closeCallbacks.push(cb), + dataCallbacks, + closeCallbacks, + written + } +} + +function makeResponseFrame(requestId: number, result: unknown, seq: number): Buffer { + const payload = Buffer.from( + JSON.stringify({ + jsonrpc: '2.0', + id: requestId, + result + }) + ) + return encodeFrame(MessageType.Regular, seq, 0, payload) +} + +function makeErrorResponseFrame( + requestId: number, + code: number, + message: string, + seq: number +): Buffer { + const payload = Buffer.from( + JSON.stringify({ + jsonrpc: '2.0', + id: requestId, + error: { code, message } + }) + ) + return encodeFrame(MessageType.Regular, seq, 0, payload) +} + +function makeNotificationFrame( + method: string, + params: Record, + seq: number +): Buffer { + const payload = Buffer.from( + JSON.stringify({ + jsonrpc: '2.0', + method, + params + }) + ) + return encodeFrame(MessageType.Regular, seq, 0, payload) +} + +describe('SshChannelMultiplexer', () => { + let transport: ReturnType + let mux: SshChannelMultiplexer + + beforeEach(() => { + vi.useFakeTimers() + transport = createMockTransport() + mux = new SshChannelMultiplexer(transport) + }) + + afterEach(() => { + mux.dispose() + vi.useRealTimers() + }) + + describe('request/response', () => { + it('sends a JSON-RPC request and resolves on response', async () => { + const promise = mux.request('pty.spawn', { cols: 80, rows: 24 }) + + // Verify the request was written + expect(transport.written.length).toBe(1) + const frame = transport.written[0] + expect(frame[0]).toBe(MessageType.Regular) + + const payloadLen = frame.readUInt32BE(9) + const payload = JSON.parse( + frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLen).toString() + ) + expect(payload.method).toBe('pty.spawn') + expect(payload.id).toBe(1) + + // Simulate response from relay + const response = makeResponseFrame(1, { id: 'pty-1' }, 1) + transport.dataCallbacks[0](response) + + const result = await promise + expect(result).toEqual({ id: 'pty-1' }) + }) + + it('rejects on error response', async () => { + const promise = mux.request('pty.spawn', { cols: 80, rows: 24 }) + + const response = makeErrorResponseFrame(1, -33004, 'PTY allocation failed', 1) + transport.dataCallbacks[0](response) + + await expect(promise).rejects.toThrow('PTY allocation failed') + }) + + it('times out after 30s with no response', async () => { + const promise = mux.request('pty.spawn') + + // Feed keepalive frames periodically to prevent the connection-level + // timeout (20s no-data) from firing before the 30s request timeout. + for (let i = 0; i < 6; i++) { + vi.advanceTimersByTime(5_000) + transport.dataCallbacks[0](encodeKeepAliveFrame(i + 1, 0)) + } + vi.advanceTimersByTime(1_000) + + await expect(promise).rejects.toThrow('timed out') + }) + + it('assigns unique request IDs', async () => { + void mux.request('method1').catch(() => {}) + void mux.request('method2').catch(() => {}) + + expect(transport.written.length).toBe(2) + const id1 = JSON.parse( + transport.written[0] + .subarray(HEADER_LENGTH, HEADER_LENGTH + transport.written[0].readUInt32BE(9)) + .toString() + ).id + const id2 = JSON.parse( + transport.written[1] + .subarray(HEADER_LENGTH, HEADER_LENGTH + transport.written[1].readUInt32BE(9)) + .toString() + ).id + expect(id1).not.toBe(id2) + }) + }) + + describe('notifications', () => { + it('sends notifications without expecting a response', () => { + mux.notify('pty.data', { id: 'pty-1', data: 'hello' }) + + expect(transport.written.length).toBe(1) + const payload = JSON.parse( + transport.written[0] + .subarray(HEADER_LENGTH, HEADER_LENGTH + transport.written[0].readUInt32BE(9)) + .toString() + ) + expect(payload.method).toBe('pty.data') + expect(payload.id).toBeUndefined() + }) + + it('dispatches incoming notifications to handler', () => { + const handler = vi.fn() + mux.onNotification(handler) + + const frame = makeNotificationFrame('pty.exit', { id: 'pty-1', code: 0 }, 1) + transport.dataCallbacks[0](frame) + + expect(handler).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: 0 }) + }) + }) + + describe('keepalive', () => { + it('sends keepalive frames periodically', () => { + const initialWrites = transport.written.length + + vi.advanceTimersByTime(5_000) + expect(transport.written.length).toBeGreaterThan(initialWrites) + + const lastFrame = transport.written.at(-1)! + expect(lastFrame[0]).toBe(MessageType.KeepAlive) + }) + }) + + describe('dispose', () => { + it('rejects all pending requests on dispose', async () => { + const promise = mux.request('pty.spawn') + + mux.dispose() + + await expect(promise).rejects.toThrow('Multiplexer disposed') + }) + + it('throws on request after dispose', async () => { + mux.dispose() + + await expect(mux.request('pty.spawn')).rejects.toThrow('Multiplexer disposed') + }) + + it('ignores notify after dispose', () => { + mux.dispose() + mux.notify('pty.data', { id: 'pty-1', data: 'x' }) + // No writes should happen after the initial keepalive writes + }) + + it('reports isDisposed correctly', () => { + expect(mux.isDisposed()).toBe(false) + mux.dispose() + expect(mux.isDisposed()).toBe(true) + }) + }) + + describe('transport close', () => { + it('disposes multiplexer when transport closes', async () => { + const promise = mux.request('pty.spawn') + + transport.closeCallbacks[0]() + + await expect(promise).rejects.toThrow('SSH connection lost, reconnecting...') + expect(mux.isDisposed()).toBe(true) + }) + }) +}) diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts new file mode 100644 index 00000000..230e4a15 --- /dev/null +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -0,0 +1,284 @@ +import { + FrameDecoder, + MessageType, + encodeJsonRpcFrame, + encodeKeepAliveFrame, + parseJsonRpcMessage, + KEEPALIVE_SEND_MS, + TIMEOUT_MS, + type DecodedFrame, + type JsonRpcMessage, + type JsonRpcRequest, + type JsonRpcResponse, + type JsonRpcNotification +} from './relay-protocol' + +export type MultiplexerTransport = { + write: (data: Buffer) => void + onData: (cb: (data: Buffer) => void) => void + onClose: (cb: () => void) => void +} + +type PendingRequest = { + resolve: (result: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +export type NotificationHandler = (method: string, params: Record) => void + +const REQUEST_TIMEOUT_MS = 30_000 + +export class SshChannelMultiplexer { + private decoder: FrameDecoder + private transport: MultiplexerTransport + private nextRequestId = 1 + private nextOutgoingSeq = 1 + private highestReceivedSeq = 0 + private highestAckedBySelf = 0 + private lastReceivedAt = Date.now() + private pendingRequests = new Map() + private notificationHandlers: NotificationHandler[] = [] + private keepaliveTimer: ReturnType | null = null + private timeoutTimer: ReturnType | null = null + private disposed = false + + // Track the oldest unacked outgoing message timestamp + private unackedTimestamps = new Map() + + constructor(transport: MultiplexerTransport) { + this.transport = transport + + this.decoder = new FrameDecoder( + (frame) => this.handleFrame(frame), + (err) => this.handleProtocolError(err) + ) + + transport.onData((data) => { + if (this.disposed) { + return + } + this.lastReceivedAt = Date.now() + this.decoder.feed(data) + }) + + transport.onClose(() => { + this.dispose('connection_lost') + }) + + this.startKeepalive() + this.startTimeoutCheck() + } + + onNotification(handler: NotificationHandler): () => void { + this.notificationHandlers.push(handler) + return () => { + const idx = this.notificationHandlers.indexOf(handler) + if (idx !== -1) { + this.notificationHandlers.splice(idx, 1) + } + } + } + + /** + * Send a JSON-RPC request and wait for the response. + */ + async request(method: string, params?: Record): Promise { + if (this.disposed) { + throw new Error('Multiplexer disposed') + } + + const id = this.nextRequestId++ + const msg: JsonRpcRequest = { + jsonrpc: '2.0', + id, + method, + ...(params !== undefined ? { params } : {}) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(id) + reject(new Error(`Request "${method}" timed out after ${REQUEST_TIMEOUT_MS}ms`)) + }, REQUEST_TIMEOUT_MS) + + this.pendingRequests.set(id, { resolve, reject, timer }) + this.sendMessage(msg) + }) + } + + /** + * Send a JSON-RPC notification (no response expected). + */ + notify(method: string, params?: Record): void { + if (this.disposed) { + return + } + + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + } + + this.sendMessage(msg) + } + + dispose(reason: 'shutdown' | 'connection_lost' = 'shutdown'): void { + if (this.disposed) { + return + } + this.disposed = true + + if (this.keepaliveTimer) { + clearInterval(this.keepaliveTimer) + this.keepaliveTimer = null + } + if (this.timeoutTimer) { + clearInterval(this.timeoutTimer) + this.timeoutTimer = null + } + + // Why: the renderer uses the error code to distinguish temporary disconnects + // (show reconnection overlay) from permanent shutdown (show error toast). + const errorMessage = + reason === 'connection_lost' ? 'SSH connection lost, reconnecting...' : 'Multiplexer disposed' + const errorCode = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' + + for (const [id, pending] of this.pendingRequests) { + clearTimeout(pending.timer) + const err = new Error(errorMessage) as Error & { code: string } + err.code = errorCode + pending.reject(err) + this.pendingRequests.delete(id) + } + + this.decoder.reset() + } + + isDisposed(): boolean { + return this.disposed + } + + // ── Private ─────────────────────────────────────────────────────── + + private sendMessage(msg: JsonRpcMessage): void { + const seq = this.nextOutgoingSeq++ + const frame = encodeJsonRpcFrame(msg, seq, this.highestReceivedSeq) + this.unackedTimestamps.set(seq, Date.now()) + this.transport.write(frame) + } + + private sendKeepAlive(): void { + if (this.disposed) { + return + } + const seq = this.nextOutgoingSeq++ + const frame = encodeKeepAliveFrame(seq, this.highestReceivedSeq) + this.unackedTimestamps.set(seq, Date.now()) + this.transport.write(frame) + } + + private handleFrame(frame: DecodedFrame): void { + // Update ack tracking + if (frame.id > this.highestReceivedSeq) { + this.highestReceivedSeq = frame.id + } + + // Process ack from remote: discard timestamps for acked messages + if (frame.ack > this.highestAckedBySelf) { + for (let i = this.highestAckedBySelf + 1; i <= frame.ack; i++) { + this.unackedTimestamps.delete(i) + } + this.highestAckedBySelf = frame.ack + } + + if (frame.type === MessageType.KeepAlive) { + return + } + + if (frame.type === MessageType.Regular) { + try { + const msg = parseJsonRpcMessage(frame.payload) + this.handleMessage(msg) + } catch (err) { + this.handleProtocolError(err) + } + } + } + + private handleMessage(msg: JsonRpcMessage): void { + if ('id' in msg && ('result' in msg || 'error' in msg)) { + this.handleResponse(msg as JsonRpcResponse) + } else if ('method' in msg && !('id' in msg)) { + this.handleNotification(msg as JsonRpcNotification) + } + // Requests from relay to client are not expected in Phase 2 + } + + private handleResponse(msg: JsonRpcResponse): void { + const pending = this.pendingRequests.get(msg.id) + if (!pending) { + return + } + + clearTimeout(pending.timer) + this.pendingRequests.delete(msg.id) + + if (msg.error) { + const err = new Error(msg.error.message) + Object.defineProperty(err, 'code', { value: msg.error.code }) + Object.defineProperty(err, 'data', { value: msg.error.data }) + pending.reject(err) + } else { + pending.resolve(msg.result) + } + } + + private handleNotification(msg: JsonRpcNotification): void { + const params = msg.params ?? {} + // Why: handlers may unsubscribe during iteration (via the returned disposer + // from onNotification), which splices the live array and skips the next handler. + // Iterating a snapshot prevents that. + const snapshot = Array.from(this.notificationHandlers) + for (const handler of snapshot) { + handler(msg.method, params) + } + } + + private startKeepalive(): void { + this.keepaliveTimer = setInterval(() => { + this.sendKeepAlive() + }, KEEPALIVE_SEND_MS) + } + + private startTimeoutCheck(): void { + this.timeoutTimer = setInterval(() => { + if (this.disposed) { + return + } + + const now = Date.now() + const noDataReceived = now - this.lastReceivedAt > TIMEOUT_MS + + // Check oldest unacked message + let oldestUnacked = Infinity + for (const ts of this.unackedTimestamps.values()) { + if (ts < oldestUnacked) { + oldestUnacked = ts + } + } + const oldestUnackedStale = oldestUnacked !== Infinity && now - oldestUnacked > TIMEOUT_MS + + // Connection considered dead when BOTH conditions met + if (noDataReceived && oldestUnackedStale) { + this.handleProtocolError(new Error('Connection timed out (no ack received)')) + } + }, KEEPALIVE_SEND_MS) + } + + private handleProtocolError(err: unknown): void { + console.warn(`[ssh-mux] Protocol error: ${err instanceof Error ? err.message : String(err)}`) + this.dispose('connection_lost') + } +} diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts new file mode 100644 index 00000000..563aecfc --- /dev/null +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from 'vitest' +import { parseSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' + +vi.mock('os', () => ({ + homedir: () => '/home/testuser' +})) + +describe('parseSshConfig', () => { + it('parses a basic host block', () => { + const config = ` +Host myserver + HostName 192.168.1.100 + User deploy + Port 2222 +` + const hosts = parseSshConfig(config) + expect(hosts).toHaveLength(1) + expect(hosts[0]).toEqual({ + host: 'myserver', + hostname: '192.168.1.100', + user: 'deploy', + port: 2222 + }) + }) + + it('parses multiple host blocks', () => { + const config = ` +Host staging + HostName staging.example.com + User admin + +Host production + HostName prod.example.com + User deploy + Port 2222 +` + const hosts = parseSshConfig(config) + expect(hosts).toHaveLength(2) + expect(hosts[0].host).toBe('staging') + expect(hosts[1].host).toBe('production') + expect(hosts[1].port).toBe(2222) + }) + + it('skips wildcard-only Host entries', () => { + const config = ` +Host * + ServerAliveInterval 60 + +Host myserver + HostName 10.0.0.1 +` + const hosts = parseSshConfig(config) + expect(hosts).toHaveLength(1) + expect(hosts[0].host).toBe('myserver') + }) + + it('skips Host entries with only pattern characters', () => { + const config = ` +Host *.example.com + User admin + +Host dev + HostName dev.example.com +` + const hosts = parseSshConfig(config) + expect(hosts).toHaveLength(1) + expect(hosts[0].host).toBe('dev') + }) + + it('parses IdentityFile with ~ expansion', () => { + const config = ` +Host myserver + HostName example.com + IdentityFile ~/.ssh/id_ed25519 +` + const hosts = parseSshConfig(config) + expect(hosts[0].identityFile).toBe('/home/testuser/.ssh/id_ed25519') + }) + + it('parses ProxyCommand and ProxyJump', () => { + const config = ` +Host internal + HostName 10.0.0.5 + ProxyCommand ssh -W %h:%p bastion + ProxyJump bastion.example.com +` + const hosts = parseSshConfig(config) + expect(hosts[0].proxyCommand).toBe('ssh -W %h:%p bastion') + expect(hosts[0].proxyJump).toBe('bastion.example.com') + }) + + it('ignores comments and blank lines', () => { + const config = ` +# This is a comment +Host myserver + # Another comment + HostName example.com + + User admin +` + const hosts = parseSshConfig(config) + expect(hosts).toHaveLength(1) + expect(hosts[0].user).toBe('admin') + }) + + it('handles case-insensitive keywords', () => { + const config = ` +Host myserver + hostname EXAMPLE.COM + user Admin + port 3022 +` + const hosts = parseSshConfig(config) + expect(hosts[0].hostname).toBe('EXAMPLE.COM') + expect(hosts[0].user).toBe('Admin') + expect(hosts[0].port).toBe(3022) + }) + + it('stops current block on Match directive', () => { + const config = ` +Host myserver + HostName example.com + +Match host *.internal + User internal-admin + +Host other + HostName other.com +` + const hosts = parseSshConfig(config) + expect(hosts).toHaveLength(2) + expect(hosts[0].host).toBe('myserver') + expect(hosts[1].host).toBe('other') + }) + + it('returns empty array for empty input', () => { + expect(parseSshConfig('')).toEqual([]) + }) + + it('uses first pattern from multi-pattern Host line', () => { + const config = ` +Host staging stage + HostName staging.example.com +` + const hosts = parseSshConfig(config) + expect(hosts).toHaveLength(1) + expect(hosts[0].host).toBe('staging') + }) + + it('defaults port to 22 for invalid port values', () => { + const config = ` +Host myserver + Port notanumber +` + const hosts = parseSshConfig(config) + expect(hosts[0].port).toBe(22) + }) +}) + +describe('sshConfigHostsToTargets', () => { + it('converts hosts to SshTarget objects', () => { + const hosts = [{ host: 'myserver', hostname: '10.0.0.1', port: 22, user: 'deploy' }] + const targets = sshConfigHostsToTargets(hosts, new Set()) + expect(targets).toHaveLength(1) + expect(targets[0]).toMatchObject({ + label: 'myserver', + host: '10.0.0.1', + port: 22, + username: 'deploy' + }) + expect(targets[0].id).toMatch(/^ssh-/) + }) + + it('uses host alias as hostname when HostName is missing', () => { + const hosts = [{ host: 'myserver' }] + const targets = sshConfigHostsToTargets(hosts, new Set()) + expect(targets[0].host).toBe('myserver') + }) + + it('skips hosts that are already imported', () => { + const hosts = [ + { host: 'existing', hostname: '10.0.0.1' }, + { host: 'new-host', hostname: '10.0.0.2' } + ] + const targets = sshConfigHostsToTargets(hosts, new Set(['existing'])) + expect(targets).toHaveLength(1) + expect(targets[0].label).toBe('new-host') + }) + + it('defaults username to empty string when not specified', () => { + const hosts = [{ host: 'nouser', hostname: '10.0.0.1' }] + const targets = sshConfigHostsToTargets(hosts, new Set()) + expect(targets[0].username).toBe('') + }) + + it('carries through identityFile, proxyCommand, and jumpHost', () => { + const hosts = [ + { + host: 'internal', + hostname: '10.0.0.5', + identityFile: '/home/user/.ssh/id_rsa', + proxyCommand: 'ssh -W %h:%p bastion', + proxyJump: 'bastion.example.com' + } + ] + const targets = sshConfigHostsToTargets(hosts, new Set()) + expect(targets[0].identityFile).toBe('/home/user/.ssh/id_rsa') + expect(targets[0].proxyCommand).toBe('ssh -W %h:%p bastion') + expect(targets[0].jumpHost).toBe('bastion.example.com') + }) +}) diff --git a/src/main/ssh/ssh-config-parser.ts b/src/main/ssh/ssh-config-parser.ts new file mode 100644 index 00000000..86c5edf6 --- /dev/null +++ b/src/main/ssh/ssh-config-parser.ts @@ -0,0 +1,150 @@ +import { readFileSync, existsSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' +import type { SshTarget } from '../../shared/ssh-types' + +export type SshConfigHost = { + host: string + hostname?: string + port?: number + user?: string + identityFile?: string + proxyCommand?: string + proxyJump?: string +} + +/** + * Parse an OpenSSH config file into structured host entries. + * Handles Host blocks with single or multiple patterns. + * Ignores wildcard-only patterns (e.g. "Host *"). + */ +export function parseSshConfig(content: string): SshConfigHost[] { + const hosts: SshConfigHost[] = [] + let current: SshConfigHost | null = null + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) { + continue + } + + const match = line.match(/^(\S+)\s+(.+)$/) + if (!match) { + continue + } + + const [, keyword, rawValue] = match + const key = keyword.toLowerCase() + const value = rawValue.trim() + + if (key === 'host') { + if (current) { + hosts.push(current) + } + + // Skip wildcard-only entries (e.g. "Host *" or "Host *.*") + const patterns = value.split(/\s+/) + const hasConcretePattern = patterns.some((p) => !p.includes('*') && !p.includes('?')) + if (!hasConcretePattern) { + current = null + continue + } + + current = { host: patterns[0] } + continue + } + + if (key === 'match') { + // Match blocks are complex conditionals — push current and skip + if (current) { + hosts.push(current) + } + current = null + continue + } + + if (!current) { + continue + } + + switch (key) { + case 'hostname': + current.hostname = value + break + case 'port': + current.port = parseInt(value, 10) || 22 + break + case 'user': + current.user = value + break + case 'identityfile': + current.identityFile = resolveHomePath(value) + break + case 'proxycommand': + current.proxyCommand = value + break + case 'proxyjump': + current.proxyJump = value + break + } + } + + if (current) { + hosts.push(current) + } + return hosts +} + +function resolveHomePath(filepath: string): string { + if (filepath.startsWith('~/') || filepath === '~') { + return join(homedir(), filepath.slice(1)) + } + return filepath +} + +/** Read and parse the user's ~/.ssh/config file. Returns empty array if not found. */ +export function loadUserSshConfig(): SshConfigHost[] { + const configPath = join(homedir(), '.ssh', 'config') + if (!existsSync(configPath)) { + return [] + } + + try { + const content = readFileSync(configPath, 'utf-8') + return parseSshConfig(content) + } catch { + console.warn(`[ssh] Failed to read SSH config at ${configPath}`) + return [] + } +} + +/** Convert parsed SSH config hosts into SshTarget objects for import. */ +export function sshConfigHostsToTargets( + hosts: SshConfigHost[], + existingTargetHosts: Set +): SshTarget[] { + const targets: SshTarget[] = [] + + for (const entry of hosts) { + const effectiveHost = entry.hostname || entry.host + const label = entry.host + + // Skip if already imported (match on label, which is the Host alias) + if (existingTargetHosts.has(label)) { + continue + } + + targets.push({ + id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + label, + host: effectiveHost, + port: entry.port ?? 22, + username: entry.user ?? '', + identityFile: entry.identityFile, + proxyCommand: entry.proxyCommand, + jumpHost: entry.proxyJump + }) + } + + return targets +} diff --git a/src/main/ssh/ssh-connection-manager.ts b/src/main/ssh/ssh-connection-manager.ts new file mode 100644 index 00000000..6e87cee1 --- /dev/null +++ b/src/main/ssh/ssh-connection-manager.ts @@ -0,0 +1,84 @@ +import type { SshTarget, SshConnectionState } from '../../shared/ssh-types' +import { SshConnection, type SshConnectionCallbacks } from './ssh-connection' + +// ── Connection Manager ────────────────────────────────────────────── +// Why: extracted from ssh-connection.ts to keep each file under the +// 300-line oxlint max-lines threshold while preserving a clear +// single-responsibility boundary (connection lifecycle vs. pool management). + +export class SshConnectionManager { + private connections = new Map() + private callbacks: SshConnectionCallbacks + // Why: two concurrent connect() calls for the same target would both pass + // the "existing" check, create two SshConnections, and orphan the first. + // This set prevents a second call from racing with an in-progress one. + private connectingTargets = new Set() + + constructor(callbacks: SshConnectionCallbacks) { + this.callbacks = callbacks + } + + async connect(target: SshTarget): Promise { + const existing = this.connections.get(target.id) + if (existing?.getState().status === 'connected') { + return existing + } + + if (this.connectingTargets.has(target.id)) { + throw new Error(`Connection to ${target.label} is already in progress`) + } + + this.connectingTargets.add(target.id) + + try { + if (existing) { + await existing.disconnect() + } + + const conn = new SshConnection(target, this.callbacks) + this.connections.set(target.id, conn) + + try { + await conn.connect() + } catch (err) { + this.connections.delete(target.id) + throw err + } + + return conn + } finally { + this.connectingTargets.delete(target.id) + } + } + + async disconnect(targetId: string): Promise { + const conn = this.connections.get(targetId) + if (!conn) { + return + } + await conn.disconnect() + this.connections.delete(targetId) + } + + getConnection(targetId: string): SshConnection | undefined { + return this.connections.get(targetId) + } + + getState(targetId: string): SshConnectionState | null { + return this.connections.get(targetId)?.getState() ?? null + } + + getAllStates(): Map { + const states = new Map() + for (const [id, conn] of this.connections) { + states.set(id, conn.getState()) + } + return states + } + + async disconnectAll(): Promise { + const disconnects = Array.from(this.connections.values()).map((c) => c.disconnect()) + await Promise.allSettled(disconnects) + this.connections.clear() + } +} diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts new file mode 100644 index 00000000..c6cfd6ab --- /dev/null +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { SshConnectionStore } from './ssh-connection-store' +import type { SshTarget } from '../../shared/ssh-types' + +const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ + loadUserSshConfigMock: vi.fn(), + sshConfigHostsToTargetsMock: vi.fn() +})) + +vi.mock('./ssh-config-parser', () => ({ + loadUserSshConfig: loadUserSshConfigMock, + sshConfigHostsToTargets: sshConfigHostsToTargetsMock +})) + +function createMockStore() { + const targets: SshTarget[] = [] + + return { + getSshTargets: vi.fn(() => [...targets]), + getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)), + addSshTarget: vi.fn((target: SshTarget) => targets.push(target)), + updateSshTarget: vi.fn((id: string, updates: Partial>) => { + const target = targets.find((t) => t.id === id) + if (!target) { + return null + } + Object.assign(target, updates) + return { ...target } + }), + removeSshTarget: vi.fn((id: string) => { + const idx = targets.findIndex((t) => t.id === id) + if (idx !== -1) { + targets.splice(idx, 1) + } + }) + } +} + +describe('SshConnectionStore', () => { + let mockStore: ReturnType + let sshStore: SshConnectionStore + + beforeEach(() => { + mockStore = createMockStore() + sshStore = new SshConnectionStore(mockStore as never) + loadUserSshConfigMock.mockReset() + sshConfigHostsToTargetsMock.mockReset() + }) + + it('listTargets delegates to store', () => { + sshStore.listTargets() + expect(mockStore.getSshTargets).toHaveBeenCalled() + }) + + it('getTarget delegates to store', () => { + sshStore.getTarget('test-id') + expect(mockStore.getSshTarget).toHaveBeenCalledWith('test-id') + }) + + it('addTarget generates an id and persists', () => { + const target = sshStore.addTarget({ + label: 'My Server', + host: 'example.com', + port: 22, + username: 'deploy' + }) + + expect(target.id).toMatch(/^ssh-/) + expect(target.label).toBe('My Server') + expect(mockStore.addSshTarget).toHaveBeenCalledWith(target) + }) + + it('updateTarget delegates to store', () => { + const original: SshTarget = { + id: 'ssh-1', + label: 'Old Name', + host: 'example.com', + port: 22, + username: 'user' + } + mockStore.addSshTarget(original) + + const result = sshStore.updateTarget('ssh-1', { label: 'New Name' }) + expect(result).toBeTruthy() + expect(mockStore.updateSshTarget).toHaveBeenCalledWith('ssh-1', { label: 'New Name' }) + }) + + it('removeTarget delegates to store', () => { + sshStore.removeTarget('ssh-1') + expect(mockStore.removeSshTarget).toHaveBeenCalledWith('ssh-1') + }) + + describe('importFromSshConfig', () => { + it('imports new hosts from SSH config', () => { + const configHosts = [{ host: 'staging', hostname: 'staging.example.com' }] + const newTargets: SshTarget[] = [ + { + id: 'ssh-new-1', + label: 'staging', + host: 'staging.example.com', + port: 22, + username: '' + } + ] + + loadUserSshConfigMock.mockReturnValue(configHosts) + sshConfigHostsToTargetsMock.mockReturnValue(newTargets) + + const result = sshStore.importFromSshConfig() + + expect(result).toEqual(newTargets) + expect(mockStore.addSshTarget).toHaveBeenCalledWith(newTargets[0]) + }) + + it('passes existing target labels to avoid duplicates', () => { + const existing: SshTarget = { + id: 'ssh-existing', + label: 'production', + host: 'prod.example.com', + port: 22, + username: 'deploy' + } + mockStore.addSshTarget(existing) + + loadUserSshConfigMock.mockReturnValue([]) + sshConfigHostsToTargetsMock.mockReturnValue([]) + + sshStore.importFromSshConfig() + + expect(sshConfigHostsToTargetsMock).toHaveBeenCalledWith([], new Set(['production'])) + }) + + it('returns empty array when no new hosts found', () => { + loadUserSshConfigMock.mockReturnValue([]) + sshConfigHostsToTargetsMock.mockReturnValue([]) + + const result = sshStore.importFromSshConfig() + expect(result).toEqual([]) + }) + }) +}) diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts new file mode 100644 index 00000000..580ca9ef --- /dev/null +++ b/src/main/ssh/ssh-connection-store.ts @@ -0,0 +1,48 @@ +import type { Store } from '../persistence' +import type { SshTarget } from '../../shared/ssh-types' +import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' + +export class SshConnectionStore { + constructor(private store: Store) {} + + listTargets(): SshTarget[] { + return this.store.getSshTargets() + } + + getTarget(id: string): SshTarget | undefined { + return this.store.getSshTarget(id) + } + + addTarget(target: Omit): SshTarget { + const full: SshTarget = { + ...target, + id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + } + this.store.addSshTarget(full) + return full + } + + updateTarget(id: string, updates: Partial>): SshTarget | null { + return this.store.updateSshTarget(id, updates) + } + + removeTarget(id: string): void { + this.store.removeSshTarget(id) + } + + /** + * Import hosts from ~/.ssh/config that don't already exist as targets. + * Returns the newly imported targets. + */ + importFromSshConfig(): SshTarget[] { + const existingLabels = new Set(this.store.getSshTargets().map((t) => t.label)) + const configHosts = loadUserSshConfig() + const newTargets = sshConfigHostsToTargets(configHosts, existingLabels) + + for (const target of newTargets) { + this.store.addSshTarget(target) + } + + return newTargets + } +} diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts new file mode 100644 index 00000000..15373929 --- /dev/null +++ b/src/main/ssh/ssh-connection-utils.ts @@ -0,0 +1,299 @@ +import { Client as SshClient } from 'ssh2' +import type { ConnectConfig, ClientChannel } from 'ssh2' +import { type ChildProcess, execFileSync } from 'child_process' +import { readFileSync } from 'fs' +import { createHash } from 'crypto' +import type { Socket as NetSocket } from 'net' +import type { SshTarget, SshConnectionState } from '../../shared/ssh-types' + +// Why: types live here (not ssh-connection.ts) to break a circular import. + +export type HostKeyVerifyRequest = { + host: string + ip: string + fingerprint: string + keyType: string +} + +export type AuthChallengeRequest = { + targetId: string + name: string + instructions: string + prompts: { prompt: string; echo: boolean }[] +} + +export type SshConnectionCallbacks = { + onStateChange: (targetId: string, state: SshConnectionState) => void + onHostKeyVerify: (req: HostKeyVerifyRequest) => Promise + onAuthChallenge: (req: AuthChallengeRequest) => Promise + onPasswordPrompt: (targetId: string) => Promise +} + +export const INITIAL_RETRY_ATTEMPTS = 5 +export const INITIAL_RETRY_DELAY_MS = 2000 +export const RECONNECT_BACKOFF_MS = [1000, 2000, 5000, 5000, 10000, 10000, 10000, 30000, 30000] +export const AUTH_CHALLENGE_TIMEOUT_MS = 60_000 +export const CONNECT_TIMEOUT_MS = 15_000 + +const TRANSIENT_ERROR_CODES = new Set([ + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'EAI_AGAIN' +]) + +export function isTransientError(err: Error): boolean { + const code = (err as NodeJS.ErrnoException).code + if (code && TRANSIENT_ERROR_CODES.has(code)) { + return true + } + if (err.message.includes('ETIMEDOUT')) { + return true + } + if (err.message.includes('ECONNREFUSED')) { + return true + } + if (err.message.includes('ECONNRESET')) { + return true + } + return false +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// Why: prevents shell injection when interpolating into ProxyCommand. +export function shellEscape(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'` +} + +// Why: ssh2 doesn't check known_hosts. Without this, every connection blocks +// on a UI prompt that isn't wired up yet, causing a silent timeout. +function isHostKnown(host: string, port: number): boolean { + try { + const lookup = port === 22 ? host : `[${host}]:${port}` + execFileSync('ssh-keygen', ['-F', lookup], { stdio: 'pipe', timeout: 3000 }) + return true + } catch { + return false + } +} + +// ── Auth handler state (passed in by the connection) ──────────────── + +export type AuthHandlerState = { + agentAttempted: boolean + keyAttempted: boolean + setState: (status: string, error?: string) => void +} + +export type ConnectConfigResult = { + config: ConnectConfig + jumpClient: SshClient | null + proxyProcess: ChildProcess | null +} +export async function buildConnectConfig( + target: SshTarget, + callbacks: SshConnectionCallbacks, + authState: AuthHandlerState +): Promise { + const config: ConnectConfig = { + host: target.host, + port: target.port, + username: target.username, + readyTimeout: CONNECT_TIMEOUT_MS, + keepaliveInterval: 5000, + keepaliveCountMax: 4, + + // Why: ssh2's hostVerifier callback form `(key, verify) => void` blocks + // the handshake until `verify(true/false)` is called. We check + // known_hosts first so trusted hosts connect without a UI prompt. + hostVerifier: (key: Buffer, verify: (accept: boolean) => void) => { + if (isHostKnown(target.host, target.port)) { + verify(true) + return + } + + const fingerprint = createHash('sha256').update(key).digest('base64') + const keyType = 'unknown' + + authState.setState('host-key-verification') + callbacks + .onHostKeyVerify({ + host: target.host, + ip: target.host, + fingerprint, + keyType + }) + .then((accepted) => { + verify(accepted) + }) + .catch(() => { + verify(false) + }) + }, + + authHandler: (methodsLeft, _partialSuccess, callback) => { + // ssh2 passes null on the first call, meaning "try whatever you want". + // Treat it as all methods available. + const methods = methodsLeft ?? ['publickey', 'keyboard-interactive', 'password'] + + // Try auth methods in order: agent -> publickey -> keyboard-interactive -> password + // The custom authHandler overrides ssh2's built-in sequence, so we must + // explicitly try agent auth here -- the config.agent field alone is not enough. + if (methods.includes('publickey') && process.env.SSH_AUTH_SOCK && !authState.agentAttempted) { + authState.agentAttempted = true + callback({ + type: 'agent' as const, + agent: process.env.SSH_AUTH_SOCK, + username: target.username + } as never) + return + } + + if (methods.includes('publickey') && target.identityFile && !authState.keyAttempted) { + authState.keyAttempted = true + try { + callback({ + type: 'publickey' as const, + username: target.username, + key: readFileSync(target.identityFile) + } as never) + return + } catch { + // Key file unreadable -- fall through to next method + } + } + + if (methods.includes('keyboard-interactive')) { + callback({ + type: 'keyboard-interactive' as const, + username: target.username, + prompt: async ( + _name: string, + instructions: string, + _lang: string, + prompts: { prompt: string; echo: boolean }[], + finish: (responses: string[]) => void + ) => { + authState.setState('auth-challenge') + + const timeoutPromise = sleep(AUTH_CHALLENGE_TIMEOUT_MS).then(() => null) + const responsePromise = callbacks.onAuthChallenge({ + targetId: target.id, + name: _name, + instructions, + prompts + }) + + const responses = await Promise.race([responsePromise, timeoutPromise]) + + if (!responses) { + finish([]) + return + } + finish(responses) + } + } as never) + return + } + + if (methods.includes('password')) { + callbacks + .onPasswordPrompt(target.id) + .then((password) => { + if (password === null) { + authState.setState('auth-failed', 'Authentication cancelled') + callback(false as never) + return + } + callback({ + type: 'password' as const, + username: target.username, + password + } as never) + }) + .catch(() => { + callback(false as never) + }) + return + } + + authState.setState('auth-failed', 'No supported authentication methods') + callback(false as never) + } + } + + // If an identity file is specified, try it for the initial attempt + if (target.identityFile) { + try { + config.privateKey = readFileSync(target.identityFile) + } catch { + // Will fall through to other auth methods + } + } + + // Try SSH agent by default + if (process.env.SSH_AUTH_SOCK) { + config.agent = process.env.SSH_AUTH_SOCK + } + + let proxyProcess: ChildProcess | null = null + if (target.proxyCommand) { + const { spawn } = await import('child_process') + const expanded = target.proxyCommand + .replace(/%h/g, shellEscape(target.host)) + .replace(/%p/g, shellEscape(String(target.port))) + .replace(/%r/g, shellEscape(target.username)) + proxyProcess = spawn('/bin/sh', ['-c', expanded], { stdio: ['pipe', 'pipe', 'pipe'] }) + // Why: a single PassThrough used for both directions creates a feedback loop — + // proxy stdout data flows through the PassThrough and gets piped right back to + // proxy stdin. Use a Duplex wrapper where reads come from stdout and writes + // go to stdin independently. + const { Duplex } = await import('stream') + const stream = new Duplex({ + read() {}, + write(chunk, _encoding, cb) { + proxyProcess!.stdin!.write(chunk, cb) + } + }) + proxyProcess.stdout!.on('data', (data) => stream.push(data)) + proxyProcess.stdout!.on('end', () => stream.push(null)) + config.sock = stream as unknown as NetSocket + } + + // Wire JumpHost: establish an intermediate SSH connection and forward a channel. + // Why: the jump client is returned to the caller so it can be destroyed on + // disconnect — otherwise the intermediate TCP connection leaks. + let jumpClient: SshClient | null = null + if (target.jumpHost && !target.proxyCommand) { + jumpClient = new SshClient() + const jumpConn = jumpClient + await new Promise((resolve, reject) => { + jumpConn.on('ready', () => resolve()) + jumpConn.on('error', (err) => reject(err)) + jumpConn.connect({ + host: target.jumpHost!, + port: 22, + username: target.username, + agent: process.env.SSH_AUTH_SOCK ?? undefined, + readyTimeout: CONNECT_TIMEOUT_MS + }) + }) + const forwardedChannel = await new Promise((resolve, reject) => { + jumpConn.forwardOut('127.0.0.1', 0, target.host, target.port, (err, channel) => { + if (err) { + reject(err) + } else { + resolve(channel) + } + }) + }) + config.sock = forwardedChannel as unknown as NetSocket + } + + return { config, jumpClient, proxyProcess } +} diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts new file mode 100644 index 00000000..fb141d64 --- /dev/null +++ b/src/main/ssh/ssh-connection.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +let eventHandlers: Map void> +let connectBehavior: 'ready' | 'error' = 'ready' +let connectErrorMessage = '' + +vi.mock('ssh2', () => ({ + Client: class MockSshClient { + on(event: string, handler: (...args: unknown[]) => void) { + eventHandlers?.set(event, handler) + } + connect() { + setTimeout(() => { + if (connectBehavior === 'error') { + eventHandlers?.get('error')?.(new Error(connectErrorMessage)) + } else { + eventHandlers?.get('ready')?.() + } + }, 0) + } + end() {} + destroy() {} + exec() {} + sftp() {} + } +})) + +vi.mock('./ssh-system-fallback', () => ({ + spawnSystemSsh: vi.fn().mockReturnValue({ + stdin: {}, + stdout: {}, + stderr: {}, + kill: vi.fn(), + onExit: vi.fn(), + pid: 99999 + }) +})) + +import { SshConnection, SshConnectionManager, type SshConnectionCallbacks } from './ssh-connection' +import type { SshTarget } from '../../shared/ssh-types' + +function createTarget(overrides?: Partial): SshTarget { + return { + id: 'target-1', + label: 'Test Server', + host: 'example.com', + port: 22, + username: 'deploy', + ...overrides + } +} + +function createCallbacks(overrides?: Partial): SshConnectionCallbacks { + return { + onStateChange: vi.fn(), + onHostKeyVerify: vi.fn().mockResolvedValue(true), + onAuthChallenge: vi.fn().mockResolvedValue(['123456']), + onPasswordPrompt: vi.fn().mockResolvedValue('password'), + ...overrides + } +} + +describe('SshConnection', () => { + beforeEach(() => { + eventHandlers = new Map() + connectBehavior = 'ready' + connectErrorMessage = '' + }) + + it('transitions to connected on successful connect', async () => { + const callbacks = createCallbacks() + const conn = new SshConnection(createTarget(), callbacks) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(callbacks.onStateChange).toHaveBeenCalledWith( + 'target-1', + expect.objectContaining({ status: 'connected' }) + ) + }) + + it('transitions through connecting → connected states', async () => { + const states: string[] = [] + const callbacks = createCallbacks({ + onStateChange: vi.fn((_id, state) => states.push(state.status)) + }) + const conn = new SshConnection(createTarget(), callbacks) + + await conn.connect() + + expect(states).toContain('connecting') + expect(states).toContain('connected') + }) + + it('reports error state on connection failure', async () => { + connectBehavior = 'error' + connectErrorMessage = 'Connection refused' + + const callbacks = createCallbacks() + const conn = new SshConnection(createTarget(), callbacks) + + await expect(conn.connect()).rejects.toThrow('Connection refused') + expect(conn.getState().status).toBe('error') + }) + + it('disconnect cleans up and sets state to disconnected', async () => { + const callbacks = createCallbacks() + const conn = new SshConnection(createTarget(), callbacks) + await conn.connect() + + await conn.disconnect() + + expect(conn.getState().status).toBe('disconnected') + }) + + it('getTarget returns a copy of the target', () => { + const target = createTarget() + const conn = new SshConnection(target, createCallbacks()) + const returned = conn.getTarget() + + expect(returned).toEqual(target) + expect(returned).not.toBe(target) + }) + + it('getState returns a copy of the state', () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + const state1 = conn.getState() + const state2 = conn.getState() + + expect(state1).toEqual(state2) + expect(state1).not.toBe(state2) + }) + + it('throws when connecting a disposed connection', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.disconnect() + + await expect(conn.connect()).rejects.toThrow('Connection disposed') + }) +}) + +describe('SshConnectionManager', () => { + beforeEach(() => { + eventHandlers = new Map() + connectBehavior = 'ready' + connectErrorMessage = '' + }) + + it('connect creates and stores a connection', async () => { + const mgr = new SshConnectionManager(createCallbacks()) + const target = createTarget() + + const conn = await mgr.connect(target) + expect(conn.getState().status).toBe('connected') + expect(mgr.getConnection(target.id)).toBe(conn) + }) + + it('getState returns connection state', async () => { + const mgr = new SshConnectionManager(createCallbacks()) + const target = createTarget() + + await mgr.connect(target) + const state = mgr.getState(target.id) + + expect(state).toBeTruthy() + expect(state!.status).toBe('connected') + }) + + it('getState returns null for unknown targets', () => { + const mgr = new SshConnectionManager(createCallbacks()) + expect(mgr.getState('unknown')).toBeNull() + }) + + it('disconnect removes the connection', async () => { + const mgr = new SshConnectionManager(createCallbacks()) + const target = createTarget() + + await mgr.connect(target) + await mgr.disconnect(target.id) + + expect(mgr.getConnection(target.id)).toBeUndefined() + }) + + it('disconnect is a no-op for unknown targets', async () => { + const mgr = new SshConnectionManager(createCallbacks()) + await mgr.disconnect('unknown') + }) + + it('reuses existing connected connection for same target', async () => { + const mgr = new SshConnectionManager(createCallbacks()) + const target = createTarget() + + const conn1 = await mgr.connect(target) + const conn2 = await mgr.connect(target) + + expect(conn2).toBe(conn1) + }) + + it('getAllStates returns all connection states', async () => { + const mgr = new SshConnectionManager(createCallbacks()) + await mgr.connect(createTarget({ id: 'a' })) + await mgr.connect(createTarget({ id: 'b' })) + + const states = mgr.getAllStates() + expect(states.size).toBe(2) + expect(states.get('a')?.status).toBe('connected') + expect(states.get('b')?.status).toBe('connected') + }) + + it('disconnectAll disconnects all connections', async () => { + const mgr = new SshConnectionManager(createCallbacks()) + await mgr.connect(createTarget({ id: 'a' })) + await mgr.connect(createTarget({ id: 'b' })) + + await mgr.disconnectAll() + + expect(mgr.getConnection('a')).toBeUndefined() + expect(mgr.getConnection('b')).toBeUndefined() + }) +}) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts new file mode 100644 index 00000000..0e2066aa --- /dev/null +++ b/src/main/ssh/ssh-connection.ts @@ -0,0 +1,371 @@ +import { Client as SshClient } from 'ssh2' +import type { ChildProcess } from 'child_process' +import type { ClientChannel, SFTPWrapper } from 'ssh2' +import type { SshTarget, SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types' +import { spawnSystemSsh, type SystemSshProcess } from './ssh-system-fallback' +import { + INITIAL_RETRY_ATTEMPTS, + INITIAL_RETRY_DELAY_MS, + RECONNECT_BACKOFF_MS, + CONNECT_TIMEOUT_MS, + isTransientError, + sleep, + buildConnectConfig, + type SshConnectionCallbacks +} from './ssh-connection-utils' +// Why: type definitions live in ssh-connection-utils.ts to break a circular +// import. Re-exported here so existing import sites keep working. +export type { + HostKeyVerifyRequest, + AuthChallengeRequest, + SshConnectionCallbacks +} from './ssh-connection-utils' + +export class SshConnection { + private client: SshClient | null = null + /** Why: the jump host client must be tracked so it can be torn down on + * disconnect — otherwise the intermediate TCP connection leaks. */ + private jumpClient: SshClient | null = null + private proxyProcess: ChildProcess | null = null + private systemSsh: SystemSshProcess | null = null + private state: SshConnectionState + private callbacks: SshConnectionCallbacks + private target: SshTarget + private reconnectTimer: ReturnType | null = null + private disposed = false + private agentAttempted = false + private keyAttempted = false + + constructor(target: SshTarget, callbacks: SshConnectionCallbacks) { + this.target = target + this.callbacks = callbacks + this.state = { + targetId: target.id, + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + } + + getState(): SshConnectionState { + return { ...this.state } + } + + getClient(): SshClient | null { + return this.client + } + + getTarget(): SshTarget { + return { ...this.target } + } + + /** Open an exec channel. Used by relay deployment to run commands on the remote. */ + async exec(command: string): Promise { + const client = this.client + if (!client) { + throw new Error('Not connected') + } + return new Promise((resolve, reject) => { + client.exec(command, (err, channel) => { + if (err) { + reject(err) + } else { + resolve(channel) + } + }) + }) + } + + /** Open an SFTP session for file transfers (relay deployment). */ + async sftp(): Promise { + const client = this.client + if (!client) { + throw new Error('Not connected') + } + return new Promise((resolve, reject) => { + client.sftp((err, sftp) => { + if (err) { + reject(err) + } else { + resolve(sftp) + } + }) + }) + } + + async connect(): Promise { + if (this.disposed) { + throw new Error('Connection disposed') + } + + let lastError: Error | null = null + + for (let attempt = 0; attempt < INITIAL_RETRY_ATTEMPTS; attempt++) { + try { + await this.attemptConnect() + return + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)) + + if (!isTransientError(lastError)) { + throw lastError + } + + if (attempt < INITIAL_RETRY_ATTEMPTS - 1) { + await sleep(INITIAL_RETRY_DELAY_MS) + } + } + } + + const finalError = lastError ?? new Error('Connection failed') + this.setState('error', finalError.message) + throw finalError + } + + private async attemptConnect(): Promise { + this.setState('connecting') + this.agentAttempted = false + this.keyAttempted = false + + // Why: clean up resources from a prior failed attempt before overwriting. + // Without this, a retry after timeout/auth-failure orphans the old jump + // host TCP connection and proxy child process. + if (this.jumpClient) { + this.jumpClient.end() + this.jumpClient = null + } + if (this.proxyProcess) { + this.proxyProcess.kill() + this.proxyProcess = null + } + + const { config, jumpClient, proxyProcess } = await this.buildConfig() + this.jumpClient = jumpClient + this.proxyProcess = proxyProcess + + return new Promise((resolve, reject) => { + const client = new SshClient() + let settled = false + + const timeout = setTimeout(() => { + if (!settled) { + settled = true + client.destroy() + const msg = `Connection timed out after ${CONNECT_TIMEOUT_MS}ms` + this.setState('error', msg) + reject(new Error(msg)) + } + }, CONNECT_TIMEOUT_MS) + + // Why: host key verification is now handled inside the hostVerifier + // callback in buildConnectConfig (ssh-connection-utils.ts). The + // callback form `(key, verify) => void` blocks the handshake until + // the user accepts/rejects, so no separate 'handshake' listener is + // needed here. + + client.on('ready', () => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + this.client = client + this.setState('connected') + this.setupDisconnectHandler(client) + resolve() + }) + + client.on('error', (err) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + this.setState('error', err.message) + reject(err) + }) + + client.connect(config) + }) + } + + private async buildConfig() { + // Why: config-building logic extracted to ssh-connection-utils.ts (max-lines). + return buildConnectConfig(this.target, this.callbacks, { + agentAttempted: this.agentAttempted, + keyAttempted: this.keyAttempted, + setState: (status: string, error?: string) => { + this.setState(status as SshConnectionStatus, error) + } + }) + } + + // Why: both `end` and `close` fire on disconnect. If reconnect succeeds + // between the two events, the second handler would null out the *new* + // connection. Guarding on `this.client === client` prevents that. + private setupDisconnectHandler(client: SshClient): void { + const handleDisconnect = () => { + if (this.disposed || this.client !== client) { + return + } + this.client = null + this.scheduleReconnect() + } + client.on('end', handleDisconnect) + client.on('close', handleDisconnect) + client.on('error', (err) => { + if (this.disposed || this.client !== client) { + return + } + console.warn(`[ssh] Connection error for ${this.target.label}: ${err.message}`) + this.client = null + this.scheduleReconnect() + }) + } + + private scheduleReconnect(): void { + if (this.disposed || this.reconnectTimer) { + return + } + + const attempt = this.state.reconnectAttempt + if (attempt >= RECONNECT_BACKOFF_MS.length) { + this.setState('reconnection-failed', 'Max reconnection attempts reached') + return + } + + this.setState('reconnecting') + const delay = RECONNECT_BACKOFF_MS[attempt] + + this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = null + if (this.disposed) { + return + } + + try { + await this.attemptConnect() + // Why: reset the counter and re-broadcast so the UI shows attempt 0. + // attemptConnect already calls setState('connected'), but the attempt + // counter must be zeroed *before* so the broadcast carries the right value. + this.state.reconnectAttempt = 0 + this.setState('connected') + } catch { + // Why: increment before scheduleReconnect so the setState('reconnecting') + // call inside it broadcasts the updated attempt number to the UI. + this.state.reconnectAttempt++ + this.scheduleReconnect() + } + }, delay) + } + + /** Fall back to system SSH binary when ssh2 cannot handle auth (FIDO2, ControlMaster). */ + async connectViaSystemSsh(): Promise { + if (this.disposed) { + throw new Error('Connection disposed') + } + // Why: if connectViaSystemSsh is called again after a prior failed attempt, + // the old process may still be running. Without cleanup, overwriting + // this.systemSsh at line 267 would orphan the old process. + if (this.systemSsh) { + this.systemSsh.kill() + this.systemSsh = null + } + this.setState('connecting') + + try { + const proc = spawnSystemSsh(this.target) + this.systemSsh = proc + + // Why: two onExit handlers are registered — one for the initial handshake + // (reject the promise on early exit) and one for post-connect reconnection. + // Without a settled flag, an early exit during handshake would fire both, + // causing the reconnection handler to schedule a reconnect for a connection + // that was never established. + let settled = false + + // Why: verify the SSH connection succeeded before reporting connected. + // Wait for relay sentinel output or a non-zero exit. + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + settled = true + reject(new Error('System SSH connection timed out')) + }, CONNECT_TIMEOUT_MS) + + proc.stdout.once('data', () => { + settled = true + clearTimeout(timeout) + resolve() + }) + proc.onExit((code) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (code !== 0) { + reject(new Error(`System SSH exited with code ${code}`)) + } + }) + }) + + this.setState('connected') + + // Why: unlike ssh2 Client which emits end/close, the system SSH process + // only signals disconnection through its exit event. Without this handler + // an unexpected exit would leave the connection in 'connected' state with + // no underlying transport. + proc.onExit((_code) => { + if (!this.disposed && this.systemSsh === proc) { + this.systemSsh = null + this.scheduleReconnect() + } + }) + + return proc + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + this.setState('error', msg) + throw err + } + } + + async disconnect(): Promise { + this.disposed = true + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + if (this.client) { + this.client.end() + this.client = null + } + // Why: the jump host client holds an open TCP connection to the + // intermediate host. Failing to close it would leak the socket. + if (this.jumpClient) { + this.jumpClient.end() + this.jumpClient = null + } + if (this.proxyProcess) { + this.proxyProcess.kill() + this.proxyProcess = null + } + if (this.systemSsh) { + this.systemSsh.kill() + this.systemSsh = null + } + this.setState('disconnected') + } + + private setState(status: SshConnectionStatus, error?: string): void { + this.state = { + ...this.state, + status, + error: error ?? null + } + this.callbacks.onStateChange(this.target.id, { ...this.state }) + } +} + +// Why: extracted to ssh-connection-manager.ts to stay under 300-line max-lines. +export { SshConnectionManager } from './ssh-connection-manager' diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts new file mode 100644 index 00000000..ad7b1e37 --- /dev/null +++ b/src/main/ssh/ssh-port-forward.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { SshPortForwardManager } from './ssh-port-forward' + +function createMockConn(forwardOutErr?: Error) { + const mockChannel = { + pipe: vi.fn().mockReturnThis(), + on: vi.fn(), + close: vi.fn() + } + const mockClient = { + forwardOut: vi.fn().mockImplementation((_bindAddr, _bindPort, _destHost, _destPort, cb) => { + if (forwardOutErr) { + cb(forwardOutErr, null) + } else { + cb(null, mockChannel) + } + }) + } + return { + getClient: vi.fn().mockReturnValue(mockClient), + mockClient, + mockChannel + } +} + +// Mock the net module to avoid real TCP listeners +vi.mock('net', () => { + const listeners = new Map void>() + return { + createServer: vi.fn().mockImplementation((connectionHandler) => { + const server = { + listen: vi.fn().mockImplementation((_port, _host, cb) => cb()), + close: vi.fn(), + on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + listeners.set(event, handler) + }), + removeListener: vi.fn(), + _connectionHandler: connectionHandler, + _listeners: listeners + } + return server + }) + } +}) + +describe('SshPortForwardManager', () => { + let manager: SshPortForwardManager + + beforeEach(() => { + manager = new SshPortForwardManager() + }) + + it('adds a port forward and returns entry', async () => { + const conn = createMockConn() + const entry = await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) + + expect(entry).toMatchObject({ + connectionId: 'conn-1', + localPort: 3000, + remoteHost: 'localhost', + remotePort: 8080 + }) + expect(entry.id).toBeDefined() + }) + + it('throws when SSH client is not connected', async () => { + const conn = { getClient: vi.fn().mockReturnValue(null) } + await expect( + manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) + ).rejects.toThrow('SSH connection is not established') + }) + + it('lists forwards filtered by connectionId', async () => { + const conn = createMockConn() + await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) + await manager.addForward('conn-2', conn as never, 3001, 'localhost', 8081) + await manager.addForward('conn-1', conn as never, 3002, 'localhost', 8082) + + expect(manager.listForwards('conn-1')).toHaveLength(2) + expect(manager.listForwards('conn-2')).toHaveLength(1) + expect(manager.listForwards()).toHaveLength(3) + }) + + it('removes a forward by id', async () => { + const conn = createMockConn() + const entry = await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) + + expect(manager.removeForward(entry.id)).toBe(true) + expect(manager.listForwards()).toHaveLength(0) + }) + + it('returns false when removing nonexistent forward', () => { + expect(manager.removeForward('nonexistent')).toBe(false) + }) + + it('removes all forwards for a connection', async () => { + const conn = createMockConn() + await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) + await manager.addForward('conn-1', conn as never, 3001, 'localhost', 8081) + await manager.addForward('conn-2', conn as never, 3002, 'localhost', 8082) + + manager.removeAllForwards('conn-1') + expect(manager.listForwards()).toHaveLength(1) + expect(manager.listForwards('conn-2')).toHaveLength(1) + }) + + it('dispose removes all forwards', async () => { + const conn = createMockConn() + await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) + await manager.addForward('conn-2', conn as never, 3001, 'localhost', 8081) + + manager.dispose() + expect(manager.listForwards()).toHaveLength(0) + }) + + it('stores label in the entry', async () => { + const conn = createMockConn() + const entry = await manager.addForward( + 'conn-1', + conn as never, + 3000, + 'localhost', + 8080, + 'Web Server' + ) + + expect(entry.label).toBe('Web Server') + }) +}) diff --git a/src/main/ssh/ssh-port-forward.ts b/src/main/ssh/ssh-port-forward.ts new file mode 100644 index 00000000..7e1c3e63 --- /dev/null +++ b/src/main/ssh/ssh-port-forward.ts @@ -0,0 +1,117 @@ +import { createServer, type Server, type Socket } from 'net' +import type { SshConnection } from './ssh-connection' + +export type PortForwardEntry = { + id: string + connectionId: string + localPort: number + remoteHost: string + remotePort: number + label?: string +} + +type ActiveForward = { + entry: PortForwardEntry + server: Server + activeSockets: Set +} + +export class SshPortForwardManager { + private forwards = new Map() + private nextId = 1 + + async addForward( + connectionId: string, + conn: SshConnection, + localPort: number, + remoteHost: string, + remotePort: number, + label?: string + ): Promise { + const id = `pf-${this.nextId++}` + const entry: PortForwardEntry = { + id, + connectionId, + localPort, + remoteHost, + remotePort, + label + } + + const client = conn.getClient() + if (!client) { + throw new Error('SSH connection is not established') + } + + const activeSockets = new Set() + + const server = createServer((socket) => { + activeSockets.add(socket) + socket.on('close', () => activeSockets.delete(socket)) + + client.forwardOut('127.0.0.1', localPort, remoteHost, remotePort, (err, channel) => { + if (err) { + socket.destroy() + return + } + socket.pipe(channel).pipe(socket) + channel.on('close', () => socket.destroy()) + socket.on('close', () => channel.close()) + }) + }) + + await new Promise((resolve, reject) => { + server.on('error', reject) + server.listen(localPort, '127.0.0.1', () => { + server.removeListener('error', reject) + resolve() + }) + }) + + this.forwards.set(id, { entry, server, activeSockets }) + return entry + } + + removeForward(id: string): boolean { + const forward = this.forwards.get(id) + if (!forward) { + return false + } + + for (const socket of forward.activeSockets) { + socket.destroy() + } + forward.server.close() + this.forwards.delete(id) + return true + } + + listForwards(connectionId?: string): PortForwardEntry[] { + const entries: PortForwardEntry[] = [] + for (const { entry } of this.forwards.values()) { + if (!connectionId || entry.connectionId === connectionId) { + entries.push(entry) + } + } + return entries + } + + removeAllForwards(connectionId: string): void { + // Why: removeForward deletes from this.forwards. Collecting IDs first + // avoids mutating the map during iteration, which is fragile if + // removeForward ever gains cascading cleanup. + const toRemove = [...this.forwards.entries()] + .filter(([, { entry }]) => entry.connectionId === connectionId) + .map(([id]) => id) + for (const id of toRemove) { + this.removeForward(id) + } + } + + dispose(): void { + const ids = [...this.forwards.keys()] + for (const id of ids) { + this.removeForward(id) + } + } +} diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts new file mode 100644 index 00000000..e4cfabee --- /dev/null +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -0,0 +1,244 @@ +import { createReadStream } from 'fs' +import type { SFTPWrapper, ClientChannel } from 'ssh2' +import type { SshConnection } from './ssh-connection' +import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' +import type { MultiplexerTransport } from './ssh-channel-multiplexer' + +// ── SFTP upload helpers ─────────────────────────────────────────────── + +export async function uploadDirectory( + sftp: SFTPWrapper, + localDir: string, + remoteDir: string +): Promise { + const { readdirSync, statSync } = await import('fs') + const { join: pathJoin } = await import('path') + + const entries = readdirSync(localDir) + for (const entry of entries) { + const localPath = pathJoin(localDir, entry) + const remotePath = `${remoteDir}/${entry}` + const stat = statSync(localPath) + + if (stat.isDirectory()) { + await mkdirSftp(sftp, remotePath) + await uploadDirectory(sftp, localPath, remotePath) + } else { + await uploadFile(sftp, localPath, remotePath) + } + } +} + +export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.mkdir(path, (err) => { + // Ignore "already exists" errors (SFTP status code 4 = SSH_FX_FAILURE) + if (err && (err as { code?: number }).code !== 4) { + reject(err) + } else { + resolve() + } + }) + }) +} + +export function uploadFile( + sftp: SFTPWrapper, + localPath: string, + remotePath: string +): Promise { + return new Promise((resolve, reject) => { + const readStream = createReadStream(localPath) + const writeStream = sftp.createWriteStream(remotePath) + + writeStream.on('close', resolve) + writeStream.on('error', reject) + readStream.on('error', reject) + + readStream.pipe(writeStream) + }) +} + +// ── Sentinel detection ──────────────────────────────────────────────── + +export function waitForSentinel(channel: ClientChannel): Promise { + return new Promise((resolve, reject) => { + let sentinelReceived = false + let stderrOutput = '' + let bufferedStdout = Buffer.alloc(0) + + const timeout = setTimeout(() => { + if (!sentinelReceived) { + channel.close() + reject( + new Error( + `Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` + ) + ) + } + }, RELAY_SENTINEL_TIMEOUT_MS) + + const MAX_BUFFER_CAP = 64 * 1024 + channel.stderr.on('data', (data: Buffer) => { + stderrOutput += data.toString('utf-8') + if (stderrOutput.length > MAX_BUFFER_CAP) { + stderrOutput = stderrOutput.slice(-MAX_BUFFER_CAP) + } + }) + + channel.on('close', () => { + if (!sentinelReceived) { + clearTimeout(timeout) + reject( + new Error( + `Relay process exited before ready.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` + ) + ) + } + }) + + const dataCallbacks: ((data: Buffer) => void)[] = [] + const closeCallbacks: (() => void)[] = [] + + channel.on('data', (data: Buffer) => { + if (sentinelReceived) { + for (const cb of dataCallbacks) { + cb(data) + } + return + } + + bufferedStdout = Buffer.concat([bufferedStdout, data]) + const text = bufferedStdout.toString('utf-8') + const sentinelIdx = text.indexOf(RELAY_SENTINEL) + + if (sentinelIdx !== -1) { + sentinelReceived = true + clearTimeout(timeout) + + const afterSentinel = bufferedStdout.subarray( + Buffer.byteLength(text.substring(0, sentinelIdx + RELAY_SENTINEL.length), 'utf-8') + ) + + const transport: MultiplexerTransport = { + write: (buf: Buffer) => channel.stdin.write(buf), + onData: (cb) => { + dataCallbacks.push(cb) + }, + onClose: (cb) => { + closeCallbacks.push(cb) + } + } + + channel.on('close', () => { + for (const cb of closeCallbacks) { + cb() + } + }) + + resolve(transport) + + if (afterSentinel.length > 0) { + for (const cb of dataCallbacks) { + cb(afterSentinel) + } + } + } + }) + }) +} + +// ── Remote command execution ────────────────────────────────────────── + +const EXEC_TIMEOUT_MS = 30_000 + +export async function execCommand(conn: SshConnection, command: string): Promise { + const channel = await conn.exec(command) + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let settled = false + + const timeout = setTimeout(() => { + if (!settled) { + settled = true + channel.close() + reject(new Error(`Command "${command}" timed out after ${EXEC_TIMEOUT_MS / 1000}s`)) + } + }, EXEC_TIMEOUT_MS) + + channel.on('data', (data: Buffer) => { + stdout += data.toString('utf-8') + }) + channel.stderr.on('data', (data: Buffer) => { + stderr += data.toString('utf-8') + }) + channel.on('close', (code: number) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (code !== 0) { + reject(new Error(`Command "${command}" failed (exit ${code}): ${stderr.trim()}`)) + } else { + resolve(stdout) + } + }) + }) +} + +// ── Remote Node.js resolution ───────────────────────────────────────── + +// Why: non-login SSH shells (the default for `exec`) don't source +// .bashrc/.zshrc, so node installed via nvm/fnm/Homebrew isn't in PATH. +// We try common locations and fall back to a login-shell `which`. +export async function resolveRemoteNodePath(conn: SshConnection): Promise { + // Why: non-login SSH exec channels don't source .bashrc/.zshrc, so node + // installed via nvm/fnm/Homebrew may not be in PATH. We probe common + // locations directly, then fall back to sourcing the profile explicitly. + // The glob in $HOME/.nvm/... is expanded by the shell, not by `command -v`. + const script = [ + 'command -v node 2>/dev/null', + 'command -v /usr/local/bin/node 2>/dev/null', + 'command -v /opt/homebrew/bin/node 2>/dev/null', + // Why: nvm installs into a versioned directory. `ls -1` sorts + // alphabetically, which misorders versions (e.g. v9 > v18). Pipe + // through `sort -V` (version sort) so we pick the highest version. + 'ls -1 $HOME/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1', + 'command -v $HOME/.local/bin/node 2>/dev/null', + 'command -v $HOME/.fnm/aliases/default/bin/node 2>/dev/null' + ].join(' || ') + + try { + const result = await execCommand(conn, script) + const nodePath = result.trim().split('\n')[0] + if (nodePath) { + console.log(`[ssh-relay] Found node at: ${nodePath}`) + return nodePath + } + } catch { + // Fall through to login shell attempt + } + + // Why: last resort — source the full login profile. This is separated into + // its own exec because `bash -lc` can hang on remotes with interactive + // shell configs (conda prompts, etc.). If this times out, the error message + // from execCommand will tell us it was the login shell attempt. + try { + console.log('[ssh-relay] Trying login shell to find node...') + const result = await execCommand(conn, "bash -lc 'command -v node' 2>/dev/null") + const nodePath = result.trim().split('\n')[0] + if (nodePath) { + console.log(`[ssh-relay] Found node via login shell: ${nodePath}`) + return nodePath + } + } catch { + // Fall through + } + + throw new Error( + 'Node.js not found on remote host. Orca relay requires Node.js 18+. ' + + 'Install Node.js on the remote and try again.' + ) +} diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts new file mode 100644 index 00000000..a841c5f3 --- /dev/null +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -0,0 +1,255 @@ +import { join } from 'path' +import { existsSync } from 'fs' +import { app } from 'electron' +import type { SshConnection } from './ssh-connection' +import { + RELAY_VERSION, + RELAY_REMOTE_DIR, + parseUnameToRelayPlatform, + type RelayPlatform +} from './relay-protocol' +import type { MultiplexerTransport } from './ssh-channel-multiplexer' +import { + uploadDirectory, + waitForSentinel, + execCommand, + resolveRemoteNodePath +} from './ssh-relay-deploy-helpers' +import { shellEscape } from './ssh-connection-utils' + +export type RelayDeployResult = { + transport: MultiplexerTransport + platform: RelayPlatform +} + +/** + * Deploy the relay to the remote host and launch it. + * + * Steps: + * 1. Detect remote OS/arch via `uname -sm` + * 2. Check if correct relay version is already deployed + * 3. If not, SCP the relay package + * 4. Launch relay via exec channel + * 5. Wait for ORCA-RELAY sentinel on stdout + * 6. Return the transport (relay's stdin/stdout) for multiplexer use + */ +export async function deployAndLaunchRelay( + conn: SshConnection, + onProgress?: (status: string) => void +): Promise { + onProgress?.('Detecting remote platform...') + console.log('[ssh-relay] Detecting remote platform...') + const platform = await detectRemotePlatform(conn) + if (!platform) { + throw new Error( + 'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64.' + ) + } + console.log(`[ssh-relay] Platform: ${platform}`) + + // Why: SFTP does not expand `~`, so we must resolve the remote home directory + // explicitly. `echo $HOME` over exec gives us the absolute path. + const remoteHome = (await execCommand(conn, 'echo $HOME')).trim() + // Why: a malicious or misconfigured remote could return a $HOME containing + // shell metacharacters. Validate it looks like a reasonable path. + if (!remoteHome || !/^\/[a-zA-Z0-9/_.-]+$/.test(remoteHome)) { + throw new Error(`Remote $HOME is not a valid path: ${remoteHome.slice(0, 100)}`) + } + const remoteRelayDir = `${remoteHome}/${RELAY_REMOTE_DIR}/relay-v${RELAY_VERSION}` + console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) + + onProgress?.('Checking existing relay...') + const localRelayDir = getLocalRelayPath(platform) + const alreadyDeployed = await checkRelayExists(conn, remoteRelayDir, localRelayDir) + console.log(`[ssh-relay] Already deployed: ${alreadyDeployed}`) + + if (!alreadyDeployed) { + onProgress?.('Uploading relay...') + console.log('[ssh-relay] Uploading relay...') + await uploadRelay(conn, platform, remoteRelayDir) + console.log('[ssh-relay] Upload complete') + + onProgress?.('Installing native dependencies...') + console.log('[ssh-relay] Installing node-pty...') + await installNativeDeps(conn, remoteRelayDir) + console.log('[ssh-relay] Native deps installed') + } + + onProgress?.('Starting relay...') + console.log('[ssh-relay] Launching relay...') + const transport = await launchRelay(conn, remoteRelayDir) + console.log('[ssh-relay] Relay started successfully') + + return { transport, platform } +} + +async function detectRemotePlatform(conn: SshConnection): Promise { + const output = await execCommand(conn, 'uname -sm') + const parts = output.trim().split(/\s+/) + if (parts.length < 2) { + return null + } + return parseUnameToRelayPlatform(parts[0], parts[1]) +} + +async function checkRelayExists( + conn: SshConnection, + remoteDir: string, + localRelayDir: string | null +): Promise { + try { + const output = await execCommand( + conn, + `test -f ${shellEscape(`${remoteDir}/relay.js`)} && echo OK || echo MISSING` + ) + if (output.trim() !== 'OK') { + return false + } + + // Why: compare against the local .version file content (which includes a + // content hash) so any code change triggers re-deploy, even without bumping + // RELAY_VERSION. Falls back to the bare RELAY_VERSION for safety. + let expectedVersion = RELAY_VERSION + if (localRelayDir) { + try { + const { readFileSync } = await import('fs') + expectedVersion = readFileSync(join(localRelayDir, '.version'), 'utf-8').trim() + } catch { + /* fall back to RELAY_VERSION */ + } + } + + const versionOutput = await execCommand( + conn, + `cat ${shellEscape(`${remoteDir}/.version`)} 2>/dev/null || echo MISSING` + ) + return versionOutput.trim() === expectedVersion + } catch { + return false + } +} + +async function uploadRelay( + conn: SshConnection, + platform: RelayPlatform, + remoteDir: string +): Promise { + const localRelayDir = getLocalRelayPath(platform) + if (!localRelayDir || !existsSync(localRelayDir)) { + throw new Error( + `Relay package for ${platform} not found at ${localRelayDir}. ` + + `This may be a packaging issue — try reinstalling Orca.` + ) + } + + // Create remote directory + await execCommand(conn, `mkdir -p ${shellEscape(remoteDir)}`) + + // Upload via SFTP + const sftp = await conn.sftp() + + try { + await uploadDirectory(sftp, localRelayDir, remoteDir) + } finally { + sftp.end() + } + + // Make the node binary executable + await execCommand(conn, `chmod +x ${shellEscape(`${remoteDir}/node`)} 2>/dev/null; true`) + + // Why: version marker includes a content hash so code changes trigger + // re-deploy even without bumping RELAY_VERSION. Read from the local build + // output so the remote marker matches exactly what checkRelayExists expects. + // Why: we write the version file via SFTP instead of a shell command to + // avoid shell injection — the version string could contain characters + // that break or escape single-quoted shell interpolation. + let versionString = RELAY_VERSION + const localVersionFile = join(localRelayDir, '.version') + if (existsSync(localVersionFile)) { + const { readFileSync } = await import('fs') + versionString = readFileSync(localVersionFile, 'utf-8').trim() + } + const versionSftp = await conn.sftp() + try { + await new Promise((resolve, reject) => { + const ws = versionSftp.createWriteStream(`${remoteDir}/.version`) + ws.on('close', resolve) + ws.on('error', reject) + ws.end(versionString) + }) + } finally { + versionSftp.end() + } +} + +// Why: node-pty is a native addon that can't be bundled by esbuild. It must +// be compiled on the remote host against its Node.js version and OS. We run +// `npm init -y && npm install node-pty` in the relay directory so +// `require('node-pty')` resolves to the local node_modules. +async function installNativeDeps(conn: SshConnection, remoteDir: string): Promise { + const nodePath = await resolveRemoteNodePath(conn) + // Why: node's bin directory must be in PATH for npm's child processes. + // npm install runs node-pty's prebuild script (`node scripts/prebuild.js`) + // which spawns `node` as a child — if node isn't in PATH, that child + // fails with exit 127 even though we invoked npm via its full path. + const nodeBinDir = nodePath.replace(/\/node$/, '') + const escapedDir = shellEscape(remoteDir) + const escapedBinDir = shellEscape(nodeBinDir) + + try { + await execCommand( + conn, + `export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && npm init -y --silent 2>/dev/null && npm install node-pty 2>&1` + ) + // Why: SFTP uploads preserve file content but not Unix execute bits. + // node-pty ships a prebuilt `spawn-helper` binary that must be executable + // for posix_spawnp to fork the PTY process. + await execCommand( + conn, + `find ${shellEscape(`${remoteDir}/node_modules/node-pty/prebuilds`)} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true` + ) + } catch (err) { + // Why: node-pty install can fail if build tools (python, make, g++) are + // missing on the remote. Log the error but don't block relay startup — + // the relay will degrade gracefully (pty.spawn returns an error). + console.warn('[ssh-relay] Failed to install node-pty:', (err as Error).message) + } +} + +function getLocalRelayPath(platform: RelayPlatform): string | null { + if (process.env.ORCA_RELAY_PATH) { + const override = join(process.env.ORCA_RELAY_PATH, platform) + if (existsSync(override)) { + return override + } + } + + // Production: bundled alongside the app + const prodPath = join(app.getAppPath(), 'resources', 'relay', platform) + if (existsSync(prodPath)) { + return prodPath + } + + // Development: built by `pnpm build:relay` into out/relay/{platform}/ + const devPath = join(app.getAppPath(), 'out', 'relay', platform) + if (existsSync(devPath)) { + return devPath + } + + return null +} + +async function launchRelay(conn: SshConnection, remoteDir: string): Promise { + // Why: Phase 1 of the plan requires Node.js on the remote. We use the + // system `node` rather than bundling a node binary, keeping the relay + // package small (~100KB JS vs ~60MB with embedded node). + // Non-login SSH shells may not have node in PATH, so we source the + // user's profile to pick up nvm/fnm/brew PATH entries. + const nodePath = await resolveRemoteNodePath(conn) + // Why: both remoteDir and nodePath come from the remote host and could + // contain shell metacharacters. Single-quote escaping prevents injection. + const channel = await conn.exec( + `cd ${shellEscape(remoteDir)} && ${shellEscape(nodePath)} relay.js --grace-time 60` + ) + return waitForSentinel(channel) +} diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts new file mode 100644 index 00000000..d25760c4 --- /dev/null +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { existsSyncMock, spawnMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('fs', () => ({ + existsSync: existsSyncMock +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +import { findSystemSsh, spawnSystemSsh } from './ssh-system-fallback' +import type { SshTarget } from '../../shared/ssh-types' + +function createTarget(overrides?: Partial): SshTarget { + return { + id: 'target-1', + label: 'Test Server', + host: 'example.com', + port: 22, + username: 'deploy', + ...overrides + } +} + +describe('findSystemSsh', () => { + beforeEach(() => { + existsSyncMock.mockReset() + }) + + it('returns the first existing ssh path', () => { + existsSyncMock.mockImplementation((p: string) => p === '/usr/bin/ssh') + expect(findSystemSsh()).toBe('/usr/bin/ssh') + }) + + it('returns null when no ssh binary is found', () => { + existsSyncMock.mockReturnValue(false) + expect(findSystemSsh()).toBeNull() + }) +}) + +describe('spawnSystemSsh', () => { + let mockProc: { + stdin: object + stdout: object + stderr: object + pid: number + on: ReturnType + kill: ReturnType + } + + beforeEach(() => { + existsSyncMock.mockReset() + spawnMock.mockReset() + + mockProc = { + stdin: { write: vi.fn(), end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + pid: 12345, + on: vi.fn(), + kill: vi.fn() + } + spawnMock.mockReturnValue(mockProc) + existsSyncMock.mockImplementation((p: string) => p === '/usr/bin/ssh') + }) + + it('spawns ssh with correct arguments for basic target', () => { + spawnSystemSsh(createTarget()) + + expect(spawnMock).toHaveBeenCalledWith( + '/usr/bin/ssh', + expect.arrayContaining(['-T', 'deploy@example.com']), + expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) + ) + }) + + it('includes port flag when not 22', () => { + spawnSystemSsh(createTarget({ port: 2222 })) + + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toContain('-p') + expect(args).toContain('2222') + }) + + it('does not include port flag when port is 22', () => { + spawnSystemSsh(createTarget({ port: 22 })) + + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).not.toContain('-p') + }) + + it('includes identity file flag', () => { + spawnSystemSsh(createTarget({ identityFile: '/home/user/.ssh/id_ed25519' })) + + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toContain('-i') + expect(args).toContain('/home/user/.ssh/id_ed25519') + }) + + it('includes jump host flag', () => { + spawnSystemSsh(createTarget({ jumpHost: 'bastion.example.com' })) + + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toContain('-J') + expect(args).toContain('bastion.example.com') + }) + + it('includes proxy command flag', () => { + spawnSystemSsh(createTarget({ proxyCommand: 'ssh -W %h:%p bastion' })) + + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toContain('-o') + expect(args).toContain('ProxyCommand=ssh -W %h:%p bastion') + }) + + it('throws when no system ssh is found', () => { + existsSyncMock.mockReturnValue(false) + expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found') + }) + + it('returns a process wrapper with kill and onExit', () => { + const result = spawnSystemSsh(createTarget()) + + expect(result.pid).toBe(12345) + expect(typeof result.kill).toBe('function') + expect(typeof result.onExit).toBe('function') + }) +}) diff --git a/src/main/ssh/ssh-system-fallback.ts b/src/main/ssh/ssh-system-fallback.ts new file mode 100644 index 00000000..bc0786cc --- /dev/null +++ b/src/main/ssh/ssh-system-fallback.ts @@ -0,0 +1,101 @@ +import { spawn, type ChildProcess } from 'child_process' +import { existsSync } from 'fs' +import type { SshTarget } from '../../shared/ssh-types' + +const SYSTEM_SSH_PATHS = + process.platform === 'win32' + ? ['C:\\Windows\\System32\\OpenSSH\\ssh.exe', 'ssh.exe'] + : ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh'] + +export type SystemSshProcess = { + stdin: NodeJS.WritableStream + stdout: NodeJS.ReadableStream + stderr: NodeJS.ReadableStream + kill: () => void + onExit: (cb: (code: number | null) => void) => void + pid: number | undefined +} + +/** + * Find the system ssh binary path. Returns null if not found. + */ +export function findSystemSsh(): string | null { + for (const candidate of SYSTEM_SSH_PATHS) { + if (existsSync(candidate)) { + return candidate + } + } + return null +} + +/** + * Spawn a system ssh process connecting to the given target. + * Used when ssh2 cannot handle the auth method (FIDO2, ControlMaster). + * + * The returned process's stdin/stdout are used as the transport for + * the relay's JSON-RPC protocol, exactly like an ssh2 channel. + */ +export function spawnSystemSsh(target: SshTarget): SystemSshProcess { + const sshPath = findSystemSsh() + if (!sshPath) { + throw new Error( + 'No system ssh binary found. Install OpenSSH to use FIDO2 keys or ControlMaster.' + ) + } + + const args = buildSshArgs(target) + const proc = spawn(sshPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + + return wrapChildProcess(proc) +} + +function buildSshArgs(target: SshTarget): string[] { + const args: string[] = [] + + args.push('-o', 'BatchMode=no') + // Forward stdin/stdout for relay communication + args.push('-T') + + if (target.port !== 22) { + args.push('-p', String(target.port)) + } + + if (target.identityFile) { + args.push('-i', target.identityFile) + } + + if (target.jumpHost) { + args.push('-J', target.jumpHost) + } + + if (target.proxyCommand) { + args.push('-o', `ProxyCommand=${target.proxyCommand}`) + } + + const userHost = target.username ? `${target.username}@${target.host}` : target.host + args.push(userHost) + + return args +} + +function wrapChildProcess(proc: ChildProcess): SystemSshProcess { + return { + stdin: proc.stdin!, + stdout: proc.stdout!, + stderr: proc.stderr!, + pid: proc.pid, + kill: () => { + try { + proc.kill('SIGTERM') + } catch { + // Process may already be dead + } + }, + onExit: (cb) => { + proc.on('exit', (code) => cb(code)) + } + } +} diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 4d80abc0..27e853e2 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -9,6 +9,7 @@ import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerPtyHandlers } from '../ipc/pty' +import { registerSshHandlers } from '../ipc/ssh' import { browserManager } from '../browser/browser-manager' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { @@ -29,6 +30,7 @@ export function attachMainWindowServices( registerRepoHandlers(mainWindow, store) registerWorktreeHandlers(mainWindow, store) registerPtyHandlers(mainWindow, runtime, getSelectedCodexHomePath) + registerSshHandlers(store, () => mainWindow) registerFileDropRelay(mainWindow) setupAutoUpdater(mainWindow, { getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt, diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 01a3556a..434efea6 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -216,6 +216,12 @@ export type PreloadApi = { pickDirectory: () => Promise clone: (args: { url: string; destination: string }) => Promise cloneAbort: () => Promise + addRemote: (args: { + connectionId: string + remotePath: string + displayName?: string + kind?: 'git' | 'folder' + }) => Promise onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void getGitUsername: (args: { repoId: string }) => Promise getBaseRefDefault: (args: { repoId: string }) => Promise @@ -243,6 +249,7 @@ export type PreloadApi = { cwd?: string env?: Record command?: string + connectionId?: string | null }) => Promise<{ id: string }> write: (id: string, data: string) => void resize: (id: string, cols: number, rows: number) => void @@ -364,36 +371,46 @@ export type PreloadApi = { claudeUsage: ClaudeUsageApi codexUsage: CodexUsageApi fs: { - readDir: (args: { dirPath: string }) => Promise + readDir: (args: { dirPath: string; connectionId?: string }) => Promise readFile: (args: { filePath: string + connectionId?: string }) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> - writeFile: (args: { filePath: string; content: string }) => Promise - createFile: (args: { filePath: string }) => Promise - createDir: (args: { dirPath: string }) => Promise - rename: (args: { oldPath: string; newPath: string }) => Promise - deletePath: (args: { targetPath: string }) => Promise + writeFile: (args: { filePath: string; content: string; connectionId?: string }) => Promise + createFile: (args: { filePath: string; connectionId?: string }) => Promise + createDir: (args: { dirPath: string; connectionId?: string }) => Promise + rename: (args: { oldPath: string; newPath: string; connectionId?: string }) => Promise + deletePath: (args: { targetPath: string; connectionId?: string }) => Promise authorizeExternalPath: (args: { targetPath: string }) => Promise stat: (args: { filePath: string + connectionId?: string }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> - listFiles: (args: { rootPath: string }) => Promise - search: (args: SearchOptions) => Promise - watchWorktree: (args: { worktreePath: string }) => Promise - unwatchWorktree: (args: { worktreePath: string }) => Promise + listFiles: (args: { rootPath: string; connectionId?: string }) => Promise + search: (args: SearchOptions & { connectionId?: string }) => Promise + watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise + unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void } git: { - status: (args: { worktreePath: string }) => Promise<{ entries: GitStatusEntry[] }> - conflictOperation: (args: { worktreePath: string }) => Promise + status: (args: { + worktreePath: string + connectionId?: string + }) => Promise<{ entries: GitStatusEntry[] }> + conflictOperation: (args: { + worktreePath: string + connectionId?: string + }) => Promise diff: (args: { worktreePath: string filePath: string staged: boolean + connectionId?: string }) => Promise branchCompare: (args: { worktreePath: string baseRef: string + connectionId?: string }) => Promise branchDiff: (args: { worktreePath: string @@ -405,16 +422,38 @@ export type PreloadApi = { } filePath: string oldPath?: string + connectionId?: string }) => Promise - stage: (args: { worktreePath: string; filePath: string }) => Promise - bulkStage: (args: { worktreePath: string; filePaths: string[] }) => Promise - unstage: (args: { worktreePath: string; filePath: string }) => Promise - bulkUnstage: (args: { worktreePath: string; filePaths: string[] }) => Promise - discard: (args: { worktreePath: string; filePath: string }) => Promise + stage: (args: { + worktreePath: string + filePath: string + connectionId?: string + }) => Promise + bulkStage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }) => Promise + unstage: (args: { + worktreePath: string + filePath: string + connectionId?: string + }) => Promise + bulkUnstage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }) => Promise + discard: (args: { + worktreePath: string + filePath: string + connectionId?: string + }) => Promise remoteFileUrl: (args: { worktreePath: string relativePath: string line: number + connectionId?: string }) => Promise } ui: { @@ -460,4 +499,55 @@ export type PreloadApi = { setPollingInterval: (ms: number) => Promise onUpdate: (callback: (state: RateLimitState) => void) => () => void } + ssh: { + listTargets: () => Promise + addTarget: (args: { target: Record }) => Promise + updateTarget: (args: { id: string; updates: Record }) => Promise + removeTarget: (args: { id: string }) => Promise + importConfig: () => Promise + connect: (args: { targetId: string }) => Promise + disconnect: (args: { targetId: string }) => Promise + getState: (args: { targetId: string }) => Promise + testConnection: (args: { + targetId: string + }) => Promise<{ success: boolean; error?: string; state?: unknown }> + onStateChanged: (callback: (data: { targetId: string; state: unknown }) => void) => () => void + onHostKeyVerify: ( + callback: (data: { + host: string + ip: string + fingerprint: string + keyType: string + responseChannel: string + }) => void + ) => () => void + respondHostKeyVerify: (args: { channel: string; accepted: boolean }) => void + onAuthChallenge: ( + callback: (data: { + targetId: string + name: string + instructions: string + prompts: { prompt: string; echo: boolean }[] + responseChannel: string + }) => void + ) => () => void + respondAuthChallenge: (args: { channel: string; responses: string[] }) => void + onPasswordPrompt: ( + callback: (data: { targetId: string; responseChannel: string }) => void + ) => () => void + respondPassword: (args: { channel: string; password: string | null }) => void + addPortForward: (args: { + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + }) => Promise + removePortForward: (args: { id: string }) => Promise + listPortForwards: (args?: { targetId?: string }) => Promise + browseDir: (args: { targetId: string; dirPath: string }) => Promise<{ + entries: { name: string; isDirectory: boolean }[] + resolvedPath: string + }> + } } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index b38c8721..7941d8a9 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -5,11 +5,18 @@ import type { CreateWorktreeArgs, OpenCodeStatusEvent } from '../../shared/types' +import type { SshTarget, SshConnectionState } from '../../shared/ssh-types' import type { PreloadApi } from './api-types' type ReposApi = { list: () => Promise add: (args: { path: string; kind?: 'git' | 'folder' }) => Promise + addRemote: (args: { + connectionId: string + remotePath: string + displayName?: string + kind?: 'git' | 'folder' + }) => Promise remove: (args: { repoId: string }) => Promise update: (args: { repoId: string @@ -44,6 +51,7 @@ type PtyApi = { rows: number cwd?: string env?: Record + connectionId?: string | null }) => Promise<{ id: string }> write: (id: string, data: string) => void resize: (id: string, cols: number, rows: number) => void @@ -112,10 +120,33 @@ type ShellApi = { copyFile: (args: { srcPath: string; destPath: string }) => Promise } +type SshApi = { + listTargets: () => Promise + addTarget: (args: { target: Omit }) => Promise + updateTarget: (args: { + id: string + updates: Partial> + }) => Promise + removeTarget: (args: { id: string }) => Promise + importConfig: () => Promise + connect: (args: { targetId: string }) => Promise + disconnect: (args: { targetId: string }) => Promise + getState: (args: { targetId: string }) => Promise + testConnection: (args: { targetId: string }) => Promise<{ success: boolean; error?: string }> + onStateChanged: ( + callback: (data: { targetId: string; state: SshConnectionState }) => void + ) => () => void + browseDir: (args: { targetId: string; dirPath: string }) => Promise<{ + entries: { name: string; isDirectory: boolean }[] + resolvedPath: string + }> +} + type Api = PreloadApi & { repos: ReposApi worktrees: WorktreesApi pty: PtyApi + ssh: SshApi } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index 303bed4e..2edde62f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -106,6 +106,13 @@ const api = { add: (args: { path: string; kind?: 'git' | 'folder' }): Promise => ipcRenderer.invoke('repos:add', args), + addRemote: (args: { + connectionId: string + remotePath: string + displayName?: string + kind?: 'git' | 'folder' + }): Promise => ipcRenderer.invoke('repos:addRemote', args), + remove: (args: { repoId: string }): Promise => ipcRenderer.invoke('repos:remove', args), update: (args: { repoId: string; updates: Record }): Promise => @@ -186,6 +193,7 @@ const api = { cwd?: string env?: Record command?: string + connectionId?: string | null }): Promise<{ id: string }> => ipcRenderer.invoke('pty:spawn', opts), write: (id: string, data: string): void => { @@ -703,29 +711,35 @@ const api = { fs: { readDir: (args: { dirPath: string + connectionId?: string }): Promise<{ name: string; isDirectory: boolean; isSymlink: boolean }[]> => ipcRenderer.invoke('fs:readDir', args), readFile: (args: { filePath: string + connectionId?: string }): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => ipcRenderer.invoke('fs:readFile', args), - writeFile: (args: { filePath: string; content: string }): Promise => - ipcRenderer.invoke('fs:writeFile', args), - createFile: (args: { filePath: string }): Promise => + writeFile: (args: { + filePath: string + content: string + connectionId?: string + }): Promise => ipcRenderer.invoke('fs:writeFile', args), + createFile: (args: { filePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:createFile', args), - createDir: (args: { dirPath: string }): Promise => + createDir: (args: { dirPath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:createDir', args), - rename: (args: { oldPath: string; newPath: string }): Promise => + rename: (args: { oldPath: string; newPath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:rename', args), - deletePath: (args: { targetPath: string }): Promise => + deletePath: (args: { targetPath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:deletePath', args), authorizeExternalPath: (args: { targetPath: string }): Promise => ipcRenderer.invoke('fs:authorizeExternalPath', args), stat: (args: { filePath: string + connectionId?: string }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => ipcRenderer.invoke('fs:stat', args), - listFiles: (args: { rootPath: string }): Promise => + listFiles: (args: { rootPath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:listFiles', args), search: (args: { query: string @@ -736,6 +750,7 @@ const api = { includePattern?: string excludePattern?: string maxResults?: number + connectionId?: string }): Promise<{ files: { filePath: string @@ -745,9 +760,9 @@ const api = { totalMatches: number truncated: boolean }> => ipcRenderer.invoke('fs:search', args), - watchWorktree: (args: { worktreePath: string }): Promise => + watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:watchWorktree', args), - unwatchWorktree: (args: { worktreePath: string }): Promise => + unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:unwatchWorktree', args), onFsChanged: (callback: (payload: FsChangedPayload) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: FsChangedPayload) => @@ -758,34 +773,58 @@ const api = { }, git: { - status: (args: { worktreePath: string }): Promise => + status: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:status', args), - conflictOperation: (args: { worktreePath: string }): Promise => + conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:conflictOperation', args), - diff: (args: { worktreePath: string; filePath: string; staged: boolean }): Promise => - ipcRenderer.invoke('git:diff', args), - branchCompare: (args: { worktreePath: string; baseRef: string }): Promise => - ipcRenderer.invoke('git:branchCompare', args), + diff: (args: { + worktreePath: string + filePath: string + staged: boolean + connectionId?: string + }): Promise => ipcRenderer.invoke('git:diff', args), + branchCompare: (args: { + worktreePath: string + baseRef: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:branchCompare', args), branchDiff: (args: { worktreePath: string compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } filePath: string oldPath?: string + connectionId?: string }): Promise => ipcRenderer.invoke('git:branchDiff', args), - stage: (args: { worktreePath: string; filePath: string }): Promise => - ipcRenderer.invoke('git:stage', args), - bulkStage: (args: { worktreePath: string; filePaths: string[] }): Promise => - ipcRenderer.invoke('git:bulkStage', args), - unstage: (args: { worktreePath: string; filePath: string }): Promise => - ipcRenderer.invoke('git:unstage', args), - bulkUnstage: (args: { worktreePath: string; filePaths: string[] }): Promise => - ipcRenderer.invoke('git:bulkUnstage', args), - discard: (args: { worktreePath: string; filePath: string }): Promise => - ipcRenderer.invoke('git:discard', args), + stage: (args: { + worktreePath: string + filePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:stage', args), + bulkStage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkStage', args), + unstage: (args: { + worktreePath: string + filePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:unstage', args), + bulkUnstage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkUnstage', args), + discard: (args: { + worktreePath: string + filePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:discard', args), remoteFileUrl: (args: { worktreePath: string relativePath: string line: number + connectionId?: string }): Promise => ipcRenderer.invoke('git:remoteFileUrl', args) }, @@ -977,6 +1016,137 @@ const api = { ipcRenderer.on('rateLimits:update', listener) return () => ipcRenderer.removeListener('rateLimits:update', listener) } + }, + + ssh: { + listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), + + addTarget: (args: { target: Record }): Promise => + ipcRenderer.invoke('ssh:addTarget', args), + + updateTarget: (args: { id: string; updates: Record }): Promise => + ipcRenderer.invoke('ssh:updateTarget', args), + + removeTarget: (args: { id: string }): Promise => + ipcRenderer.invoke('ssh:removeTarget', args), + + importConfig: (): Promise => ipcRenderer.invoke('ssh:importConfig'), + + connect: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:connect', args), + + disconnect: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:disconnect', args), + + getState: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:getState', args), + + testConnection: (args: { + targetId: string + }): Promise<{ success: boolean; error?: string; state?: unknown }> => + ipcRenderer.invoke('ssh:testConnection', args), + + onStateChanged: ( + callback: (data: { targetId: string; state: unknown }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; state: unknown } + ) => callback(data) + ipcRenderer.on('ssh:state-changed', listener) + return () => ipcRenderer.removeListener('ssh:state-changed', listener) + }, + + onHostKeyVerify: ( + callback: (data: { + host: string + ip: string + fingerprint: string + keyType: string + responseChannel: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + host: string + ip: string + fingerprint: string + keyType: string + responseChannel: string + } + ) => callback(data) + ipcRenderer.on('ssh:host-key-verify', listener) + return () => ipcRenderer.removeListener('ssh:host-key-verify', listener) + }, + + respondHostKeyVerify: (args: { channel: string; accepted: boolean }): void => { + ipcRenderer.send(args.channel, args.accepted) + }, + + onAuthChallenge: ( + callback: (data: { + targetId: string + name: string + instructions: string + prompts: { prompt: string; echo: boolean }[] + responseChannel: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + targetId: string + name: string + instructions: string + prompts: { prompt: string; echo: boolean }[] + responseChannel: string + } + ) => callback(data) + ipcRenderer.on('ssh:auth-challenge', listener) + return () => ipcRenderer.removeListener('ssh:auth-challenge', listener) + }, + + respondAuthChallenge: (args: { channel: string; responses: string[] }): void => { + ipcRenderer.send(args.channel, args.responses) + }, + + onPasswordPrompt: ( + callback: (data: { targetId: string; responseChannel: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; responseChannel: string } + ) => callback(data) + ipcRenderer.on('ssh:password-prompt', listener) + return () => ipcRenderer.removeListener('ssh:password-prompt', listener) + }, + + respondPassword: (args: { channel: string; password: string | null }): void => { + ipcRenderer.send(args.channel, args.password) + }, + + addPortForward: (args: { + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + }): Promise => ipcRenderer.invoke('ssh:addPortForward', args), + + removePortForward: (args: { id: string }): Promise => + ipcRenderer.invoke('ssh:removePortForward', args), + + listPortForwards: (args?: { targetId?: string }): Promise => + ipcRenderer.invoke('ssh:listPortForwards', args), + + browseDir: (args: { + targetId: string + dirPath: string + }): Promise<{ + entries: { name: string; isDirectory: boolean }[] + resolvedPath: string + }> => ipcRenderer.invoke('ssh:browseDir', args) } } diff --git a/src/relay/context.ts b/src/relay/context.ts new file mode 100644 index 00000000..42b856d5 --- /dev/null +++ b/src/relay/context.ts @@ -0,0 +1,70 @@ +import { resolve, relative, isAbsolute } from 'path' +import { realpathSync } from 'fs' +import { realpath } from 'fs/promises' + +// Why: mutating FS operations on the remote must be scoped to workspace roots +// registered by the main process. Without this, a compromised or buggy client +// could delete arbitrary files on the remote host. +export class RelayContext { + readonly authorizedRoots = new Set() + + // Why: before any root is registered there is a race window where + // authorizedRoots is empty. If we allowed all paths during that window a + // compromised client could read or mutate arbitrary files before the first + // workspace root is registered. We track registration explicitly and reject + // every validatePath call until at least one root has been added. + private rootsRegistered = false + + registerRoot(rootPath: string): void { + const resolved = resolve(rootPath) + this.authorizedRoots.add(resolved) + // Why: on macOS, /tmp is a symlink to /private/tmp. If a root is registered + // as /tmp/workspace, validatePathResolved would resolve it to /private/tmp/ + // workspace, which fails the textual root check. Register both forms so the + // resolved path also passes validation. + try { + const real = realpathSync(resolved) + if (real !== resolved) { + this.authorizedRoots.add(real) + } + } catch { + // Root doesn't exist yet — textual form is sufficient + } + this.rootsRegistered = true + } + + validatePath(targetPath: string): void { + if (!this.rootsRegistered) { + throw new Error('No workspace roots registered yet — path validation denied') + } + + const resolved = resolve(targetPath) + for (const root of this.authorizedRoots) { + const rel = relative(root, resolved) + if (!rel.startsWith('..') && !isAbsolute(rel)) { + return + } + } + throw new Error(`Path outside authorized workspace: ${targetPath}`) + } + + // Why: validatePath only normalizes `..` textually. A symlink inside the + // workspace pointing outside it (e.g., workspace/evil -> /etc/) would pass + // textual validation. This async variant resolves symlinks via realpath + // before checking the path, closing the symlink traversal vector. + async validatePathResolved(targetPath: string): Promise { + this.validatePath(targetPath) + try { + const real = await realpath(targetPath) + this.validatePath(real) + } catch (err) { + // Why: ENOENT/ENOTDIR means the path doesn't exist yet (e.g., createFile) + // so the textual check above is sufficient. Other errors (EACCES, EIO) + // indicate real problems that should propagate. + const code = (err as NodeJS.ErrnoException).code + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw err + } + } + } +} diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts new file mode 100644 index 00000000..96c1e653 --- /dev/null +++ b/src/relay/dispatcher.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { RelayDispatcher } from './dispatcher' +import { + encodeJsonRpcFrame, + encodeKeepAliveFrame, + MessageType, + type JsonRpcRequest, + type JsonRpcNotification +} from './protocol' + +function decodeFirstFrame(buf: Buffer): { type: number; id: number; ack: number; payload: Buffer } { + const type = buf[0] + const id = buf.readUInt32BE(1) + const ack = buf.readUInt32BE(5) + const len = buf.readUInt32BE(9) + const payload = buf.subarray(13, 13 + len) + return { type, id, ack, payload } +} + +describe('RelayDispatcher', () => { + let dispatcher: RelayDispatcher + let written: Buffer[] + + beforeEach(() => { + vi.useFakeTimers() + written = [] + dispatcher = new RelayDispatcher((data) => { + written.push(Buffer.from(data)) + }) + }) + + afterEach(() => { + dispatcher.dispose() + vi.useRealTimers() + }) + + it('sends keepalive frames on interval', () => { + expect(written.length).toBe(0) + + vi.advanceTimersByTime(5_000) + expect(written.length).toBe(1) + + const frame = decodeFirstFrame(written[0]) + expect(frame.type).toBe(MessageType.KeepAlive) + expect(frame.id).toBe(1) + }) + + it('dispatches JSON-RPC requests to registered handlers', async () => { + const handler = vi.fn().mockResolvedValue({ result: 42 }) + dispatcher.onRequest('test.method', handler) + + const req: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: 'test.method', + params: { foo: 'bar' } + } + const frame = encodeJsonRpcFrame(req, 1, 0) + dispatcher.feed(frame) + + // Let the handler promise resolve + await vi.advanceTimersByTimeAsync(0) + + expect(handler).toHaveBeenCalledWith({ foo: 'bar' }) + + // Should have sent a response (after keepalive timer writes) + const responses = written.filter((buf) => { + const f = decodeFirstFrame(buf) + if (f.type !== MessageType.Regular) { + return false + } + try { + const msg = JSON.parse(f.payload.toString('utf-8')) + return 'id' in msg && 'result' in msg + } catch { + return false + } + }) + expect(responses.length).toBe(1) + + const resp = JSON.parse(decodeFirstFrame(responses[0]).payload.toString('utf-8')) + expect(resp.result).toEqual({ result: 42 }) + expect(resp.id).toBe(1) + }) + + it('sends error response when handler throws', async () => { + dispatcher.onRequest('fail.method', async () => { + throw new Error('boom') + }) + + const req: JsonRpcRequest = { + jsonrpc: '2.0', + id: 5, + method: 'fail.method' + } + dispatcher.feed(encodeJsonRpcFrame(req, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + + const errors = written.filter((buf) => { + const f = decodeFirstFrame(buf) + if (f.type !== MessageType.Regular) { + return false + } + try { + const msg = JSON.parse(f.payload.toString('utf-8')) + return 'error' in msg + } catch { + return false + } + }) + expect(errors.length).toBe(1) + + const resp = JSON.parse(decodeFirstFrame(errors[0]).payload.toString('utf-8')) + expect(resp.error.message).toBe('boom') + expect(resp.id).toBe(5) + }) + + it('sends method-not-found for unknown methods', async () => { + const req: JsonRpcRequest = { + jsonrpc: '2.0', + id: 10, + method: 'unknown.method' + } + dispatcher.feed(encodeJsonRpcFrame(req, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + + const errors = written.filter((buf) => { + const f = decodeFirstFrame(buf) + if (f.type !== MessageType.Regular) { + return false + } + try { + const msg = JSON.parse(f.payload.toString('utf-8')) + return msg.error?.code === -32601 + } catch { + return false + } + }) + expect(errors.length).toBe(1) + }) + + it('dispatches notifications to registered handlers', () => { + const handler = vi.fn() + dispatcher.onNotification('event.happened', handler) + + const notif: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'event.happened', + params: { x: 1 } + } + dispatcher.feed(encodeJsonRpcFrame(notif, 1, 0)) + + expect(handler).toHaveBeenCalledWith({ x: 1 }) + }) + + it('sends notifications via notify()', () => { + dispatcher.notify('my.event', { data: 'hello' }) + + const notifs = written.filter((buf) => { + const f = decodeFirstFrame(buf) + if (f.type !== MessageType.Regular) { + return false + } + try { + const msg = JSON.parse(f.payload.toString('utf-8')) + return 'method' in msg && !('id' in msg) + } catch { + return false + } + }) + expect(notifs.length).toBe(1) + + const msg = JSON.parse(decodeFirstFrame(notifs[0]).payload.toString('utf-8')) + expect(msg.method).toBe('my.event') + expect(msg.params).toEqual({ data: 'hello' }) + }) + + it('tracks highest received seq in ack field', async () => { + const handler = vi.fn().mockResolvedValue('ok') + dispatcher.onRequest('ping', handler) + + // Send request with seq=50 + const req: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'ping' } + dispatcher.feed(encodeJsonRpcFrame(req, 50, 0)) + await vi.advanceTimersByTimeAsync(0) + + // The response frame should have ack=50 + const responseFrames = written.filter((buf) => { + const f = decodeFirstFrame(buf) + if (f.type !== MessageType.Regular) { + return false + } + try { + const msg = JSON.parse(f.payload.toString('utf-8')) + return 'result' in msg + } catch { + return false + } + }) + expect(responseFrames.length).toBe(1) + expect(decodeFirstFrame(responseFrames[0]).ack).toBe(50) + }) + + it('silently handles keepalive frames', () => { + const frame = encodeKeepAliveFrame(1, 0) + // Should not throw + dispatcher.feed(frame) + }) + + it('stops sending after dispose', () => { + dispatcher.dispose() + const before = written.length + dispatcher.notify('test', {}) + expect(written.length).toBe(before) + + vi.advanceTimersByTime(10_000) + expect(written.length).toBe(before) + }) +}) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts new file mode 100644 index 00000000..d8d46580 --- /dev/null +++ b/src/relay/dispatcher.ts @@ -0,0 +1,170 @@ +import { + FrameDecoder, + MessageType, + encodeJsonRpcFrame, + encodeKeepAliveFrame, + parseJsonRpcMessage, + KEEPALIVE_SEND_MS, + type DecodedFrame, + type JsonRpcRequest, + type JsonRpcNotification, + type JsonRpcResponse +} from './protocol' + +export type MethodHandler = (params: Record) => Promise + +export type NotificationHandler = (params: Record) => void + +export class RelayDispatcher { + private decoder: FrameDecoder + private write: (data: Buffer) => void + private requestHandlers = new Map() + private notificationHandlers = new Map() + private nextOutgoingSeq = 1 + private highestReceivedSeq = 0 + private keepaliveTimer: ReturnType | null = null + private disposed = false + + constructor(write: (data: Buffer) => void) { + this.write = write + this.decoder = new FrameDecoder((frame) => this.handleFrame(frame)) + this.startKeepalive() + } + + onRequest(method: string, handler: MethodHandler): void { + this.requestHandlers.set(method, handler) + } + + onNotification(method: string, handler: NotificationHandler): void { + this.notificationHandlers.set(method, handler) + } + + feed(data: Buffer): void { + if (this.disposed) { + return + } + try { + this.decoder.feed(data) + } catch (err) { + process.stderr.write( + `[relay] Protocol error: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + } + + notify(method: string, params?: Record): void { + if (this.disposed) { + return + } + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + } + this.sendFrame(msg) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + if (this.keepaliveTimer) { + clearInterval(this.keepaliveTimer) + this.keepaliveTimer = null + } + } + + private handleFrame(frame: DecodedFrame): void { + if (frame.id > this.highestReceivedSeq) { + this.highestReceivedSeq = frame.id + } + + if (frame.type === MessageType.KeepAlive) { + return + } + + if (frame.type === MessageType.Regular) { + try { + const msg = parseJsonRpcMessage(frame.payload) + this.handleMessage(msg) + } catch (err) { + process.stderr.write( + `[relay] Parse error: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + } + } + + private handleMessage(msg: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): void { + if ('id' in msg && 'method' in msg) { + void this.handleRequest(msg as JsonRpcRequest) + } else if ('method' in msg && !('id' in msg)) { + this.handleNotification(msg as JsonRpcNotification) + } + } + + private async handleRequest(req: JsonRpcRequest): Promise { + const handler = this.requestHandlers.get(req.method) + if (!handler) { + this.sendResponse(req.id, undefined, { + code: -32601, + message: `Method not found: ${req.method}` + }) + return + } + + try { + const result = await handler(req.params ?? {}) + this.sendResponse(req.id, result) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const code = (err as { code?: number }).code ?? -32000 + this.sendResponse(req.id, undefined, { code, message }) + } + } + + private handleNotification(notif: JsonRpcNotification): void { + const handler = this.notificationHandlers.get(notif.method) + if (handler) { + handler(notif.params ?? {}) + } + } + + private sendResponse( + id: number, + result?: unknown, + error?: { code: number; message: string; data?: unknown } + ): void { + const msg: JsonRpcResponse = { + jsonrpc: '2.0', + id, + ...(error ? { error } : { result: result ?? null }) + } + this.sendFrame(msg) + } + + private sendFrame(msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification): void { + if (this.disposed) { + return + } + const seq = this.nextOutgoingSeq++ + const frame = encodeJsonRpcFrame(msg, seq, this.highestReceivedSeq) + this.write(frame) + } + + private startKeepalive(): void { + this.keepaliveTimer = setInterval(() => { + if (this.disposed) { + return + } + const seq = this.nextOutgoingSeq++ + const frame = encodeKeepAliveFrame(seq, this.highestReceivedSeq) + this.write(frame) + }, KEEPALIVE_SEND_MS) + // Why: without unref, the keepalive interval keeps the event loop alive + // even when the relay should be winding down (e.g. after stdin ends and + // all PTYs have exited). unref lets the process exit naturally. + this.keepaliveTimer.unref() + } +} diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts new file mode 100644 index 00000000..96970d0f --- /dev/null +++ b/src/relay/fs-handler-utils.ts @@ -0,0 +1,288 @@ +/** + * Pure helpers and child-process search utilities extracted from fs-handler.ts. + * + * Why: oxlint max-lines requires .ts files to stay under 300 lines. + * These functions depend only on their arguments (plus `rg` being on PATH), + * so they are straightforward to test independently. + */ +import { relative } from 'path' +import { execFile, type ChildProcess } from 'child_process' + +// ─── Constants ─────────────────────────────────────────────────────── + +export const MAX_FILE_SIZE = 5 * 1024 * 1024 +export const SEARCH_TIMEOUT_MS = 15_000 +export const MAX_MATCHES_PER_FILE = 100 +export const DEFAULT_MAX_RESULTS = 2000 + +export const IMAGE_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' +} + +// ─── Binary detection ──────────────────────────────────────────────── + +export function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, 8192) + for (let i = 0; i < len; i++) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +// ─── Search types ──────────────────────────────────────────────────── + +export type SearchOptions = { + caseSensitive?: boolean + wholeWord?: boolean + useRegex?: boolean + includePattern?: string + excludePattern?: string + maxResults: number +} + +type FileResult = { + filePath: string + relativePath: string + matches: { + line: number + column: number + matchLength: number + lineContent: string + }[] +} + +export type SearchResult = { + files: FileResult[] + totalMatches: number + truncated: boolean +} + +// ─── rg-based search ───────────────────────────────────────────────── + +/** + * Run ripgrep (`rg`) with JSON output to collect text matches. + * Returns a structured result that the relay can send to the client. + */ +export function searchWithRg( + rootPath: string, + query: string, + opts: SearchOptions +): Promise { + return new Promise((resolve) => { + const rgArgs = [ + '--json', + '--hidden', + '--glob', + '!.git', + '--max-count', + String(MAX_MATCHES_PER_FILE), + '--max-filesize', + `${Math.floor(MAX_FILE_SIZE / 1024 / 1024)}M` + ] + + if (!opts.caseSensitive) { + rgArgs.push('--ignore-case') + } + if (opts.wholeWord) { + rgArgs.push('--word-regexp') + } + if (!opts.useRegex) { + rgArgs.push('--fixed-strings') + } + if (opts.includePattern) { + for (const p of opts.includePattern + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + rgArgs.push('--glob', p) + } + } + if (opts.excludePattern) { + for (const p of opts.excludePattern + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + rgArgs.push('--glob', `!${p}`) + } + } + rgArgs.push('--', query, rootPath) + + const fileMap = new Map() + let totalMatches = 0 + let truncated = false + let buffer = '' + let resolved = false + let child: ChildProcess | null = null + + const resolveOnce = () => { + if (resolved) { + return + } + resolved = true + clearTimeout(killTimeout) + resolve({ files: Array.from(fileMap.values()), totalMatches, truncated }) + } + + try { + child = execFile('rg', rgArgs, { maxBuffer: 50 * 1024 * 1024 }) + } catch { + resolve({ files: [], totalMatches: 0, truncated: false }) + return + } + + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', (chunk: string) => { + buffer += chunk + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line || totalMatches >= opts.maxResults) { + continue + } + try { + const msg = JSON.parse(line) + if (msg.type !== 'match') { + continue + } + const data = msg.data + const absPath = data.path.text as string + const relPath = relative(rootPath, absPath).replace(/\\/g, '/') + + let fileResult = fileMap.get(absPath) + if (!fileResult) { + fileResult = { filePath: absPath, relativePath: relPath, matches: [] } + fileMap.set(absPath, fileResult) + } + for (const sub of data.submatches) { + fileResult.matches.push({ + line: data.line_number, + column: sub.start + 1, + matchLength: sub.end - sub.start, + lineContent: data.lines.text.replace(/\n$/, '') + }) + totalMatches++ + if (totalMatches >= opts.maxResults) { + truncated = true + child?.kill() + break + } + } + } catch { + /* skip malformed */ + } + } + }) + child.stderr!.on('data', () => { + /* drain */ + }) + child.once('error', () => resolveOnce()) + child.once('close', () => { + if (buffer && totalMatches < opts.maxResults) { + try { + const msg = JSON.parse(buffer) + if (msg.type === 'match') { + const data = msg.data + const absPath = data.path.text as string + const relPath = relative(rootPath, absPath).replace(/\\/g, '/') + + let fileResult = fileMap.get(absPath) + if (!fileResult) { + fileResult = { filePath: absPath, relativePath: relPath, matches: [] } + fileMap.set(absPath, fileResult) + } + for (const sub of data.submatches) { + fileResult.matches.push({ + line: data.line_number, + column: sub.start + 1, + matchLength: sub.end - sub.start, + lineContent: data.lines.text.replace(/\n$/, '') + }) + totalMatches++ + if (totalMatches >= opts.maxResults) { + truncated = true + break + } + } + } + } catch { + /* skip malformed */ + } + } + resolveOnce() + }) + + const killTimeout = setTimeout(() => { + truncated = true + child?.kill() + }, SEARCH_TIMEOUT_MS) + }) +} + +// ─── rg-based file listing ─────────────────────────────────────────── + +/** + * List all non-ignored files under `rootPath` using ripgrep's `--files` mode. + * Returns relative POSIX paths. + */ +export function listFilesWithRg(rootPath: string): Promise { + return new Promise((resolve) => { + const files: string[] = [] + let buffer = '' + let done = false + + const finish = () => { + if (done) { + return + } + done = true + clearTimeout(timer) + resolve(files) + } + + const child = execFile( + 'rg', + ['--files', '--hidden', '--glob', '!**/node_modules', '--glob', '!**/.git', rootPath], + { maxBuffer: 50 * 1024 * 1024 } + ) + + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', (chunk: string) => { + buffer += chunk + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line) { + continue + } + const relPath = relative(rootPath, line).replace(/\\/g, '/') + if (!relPath.startsWith('..')) { + files.push(relPath) + } + } + }) + child.stderr!.on('data', () => { + /* drain */ + }) + child.once('error', () => finish()) + child.once('close', () => { + if (buffer) { + const relPath = relative(rootPath, buffer.trim()).replace(/\\/g, '/') + if (relPath && !relPath.startsWith('..')) { + files.push(relPath) + } + } + finish() + }) + const timer = setTimeout(() => child.kill(), 10_000) + }) +} diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts new file mode 100644 index 00000000..2e4325d8 --- /dev/null +++ b/src/relay/fs-handler.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { FsHandler } from './fs-handler' +import { RelayContext } from './context' +import type { RelayDispatcher } from './dispatcher' +import * as fs from 'fs/promises' +import * as path from 'path' +import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from 'fs' +import { tmpdir } from 'os' + +function createMockDispatcher() { + const requestHandlers = new Map) => Promise>() + const notificationHandlers = new Map) => void>() + const notifications: { method: string; params?: Record }[] = [] + + return { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ), + onNotification: vi.fn((method: string, handler: (params: Record) => void) => { + notificationHandlers.set(method, handler) + }), + notify: vi.fn((method: string, params?: Record) => { + notifications.push({ method, params }) + }), + _requestHandlers: requestHandlers, + _notificationHandlers: notificationHandlers, + _notifications: notifications, + async callRequest(method: string, params: Record = {}) { + const handler = requestHandlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + return handler(params) + }, + callNotification(method: string, params: Record = {}) { + const handler = notificationHandlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + handler(params) + } + } +} + +describe('FsHandler', () => { + let dispatcher: ReturnType + let handler: FsHandler + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-fs-')) + dispatcher = createMockDispatcher() + const ctx = new RelayContext() + ctx.registerRoot(tmpDir) + handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx) + }) + + afterEach(async () => { + handler.dispose() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it('registers all expected handlers', () => { + const methods = Array.from(dispatcher._requestHandlers.keys()) + expect(methods).toContain('fs.readDir') + expect(methods).toContain('fs.readFile') + expect(methods).toContain('fs.writeFile') + expect(methods).toContain('fs.stat') + expect(methods).toContain('fs.deletePath') + expect(methods).toContain('fs.createFile') + expect(methods).toContain('fs.createDir') + expect(methods).toContain('fs.rename') + expect(methods).toContain('fs.copy') + expect(methods).toContain('fs.realpath') + expect(methods).toContain('fs.search') + expect(methods).toContain('fs.listFiles') + expect(methods).toContain('fs.watch') + + const notifMethods = Array.from(dispatcher._notificationHandlers.keys()) + expect(notifMethods).toContain('fs.unwatch') + }) + + it('readDir returns sorted entries with directories first', async () => { + mkdirSync(path.join(tmpDir, 'subdir')) + writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') + writeFileSync(path.join(tmpDir, 'aaa.txt'), 'world') + + const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as { + name: string + isDirectory: boolean + }[] + expect(result[0].name).toBe('subdir') + expect(result[0].isDirectory).toBe(true) + expect(result.find((e) => e.name === 'file.txt')).toBeDefined() + expect(result.find((e) => e.name === 'aaa.txt')).toBeDefined() + }) + + it('readFile returns text content for text files', async () => { + const filePath = path.join(tmpDir, 'test.txt') + writeFileSync(filePath, 'hello world') + + const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as { + content: string + isBinary: boolean + } + expect(result.content).toBe('hello world') + expect(result.isBinary).toBe(false) + }) + + it('readFile returns base64 for image files', async () => { + const filePath = path.join(tmpDir, 'test.png') + writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])) + + const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as { + content: string + isBinary: boolean + isImage: boolean + mimeType: string + } + expect(result.isBinary).toBe(true) + expect(result.isImage).toBe(true) + expect(result.mimeType).toBe('image/png') + expect(result.content).toBeTruthy() + }) + + it('readFile throws for files exceeding size limit', async () => { + const filePath = path.join(tmpDir, 'huge.txt') + // Write 6MB file + writeFileSync(filePath, Buffer.alloc(6 * 1024 * 1024)) + + await expect(dispatcher.callRequest('fs.readFile', { filePath })).rejects.toThrow( + 'File too large' + ) + }) + + it('writeFile creates/overwrites file content', async () => { + const filePath = path.join(tmpDir, 'write-test.txt') + await dispatcher.callRequest('fs.writeFile', { filePath, content: 'new content' }) + + const content = await fs.readFile(filePath, 'utf-8') + expect(content).toBe('new content') + }) + + it('stat returns file metadata', async () => { + const filePath = path.join(tmpDir, 'stat-test.txt') + writeFileSync(filePath, 'test') + + const result = (await dispatcher.callRequest('fs.stat', { filePath })) as { + size: number + type: string + mtime: number + } + expect(result.type).toBe('file') + expect(result.size).toBe(4) + expect(typeof result.mtime).toBe('number') + }) + + it('stat returns directory type for directories', async () => { + const result = (await dispatcher.callRequest('fs.stat', { filePath: tmpDir })) as { + type: string + } + expect(result.type).toBe('directory') + }) + + it('deletePath removes files', async () => { + const filePath = path.join(tmpDir, 'to-delete.txt') + writeFileSync(filePath, 'bye') + + await dispatcher.callRequest('fs.deletePath', { targetPath: filePath }) + await expect(fs.access(filePath)).rejects.toThrow() + }) + + it('createFile creates an empty file with parent dirs', async () => { + const filePath = path.join(tmpDir, 'deep', 'nested', 'file.txt') + await dispatcher.callRequest('fs.createFile', { filePath }) + + const content = await fs.readFile(filePath, 'utf-8') + expect(content).toBe('') + }) + + it('createDir creates directories recursively', async () => { + const dirPath = path.join(tmpDir, 'a', 'b', 'c') + await dispatcher.callRequest('fs.createDir', { dirPath }) + + const stats = await fs.stat(dirPath) + expect(stats.isDirectory()).toBe(true) + }) + + it('rename moves files', async () => { + const oldPath = path.join(tmpDir, 'old.txt') + const newPath = path.join(tmpDir, 'new.txt') + writeFileSync(oldPath, 'content') + + await dispatcher.callRequest('fs.rename', { oldPath, newPath }) + + await expect(fs.access(oldPath)).rejects.toThrow() + const content = await fs.readFile(newPath, 'utf-8') + expect(content).toBe('content') + }) + + it('copy duplicates files', async () => { + const src = path.join(tmpDir, 'src.txt') + const dst = path.join(tmpDir, 'dst.txt') + writeFileSync(src, 'original') + + await dispatcher.callRequest('fs.copy', { source: src, destination: dst }) + + const content = await fs.readFile(dst, 'utf-8') + expect(content).toBe('original') + }) + + it('realpath resolves symlinks', async () => { + const realFile = path.join(tmpDir, 'real.txt') + const linkPath = path.join(tmpDir, 'link.txt') + writeFileSync(realFile, 'real') + symlinkSync(realFile, linkPath) + + const result = (await dispatcher.callRequest('fs.realpath', { filePath: linkPath })) as string + // On macOS, /var is a symlink to /private/var, so resolve both to compare + const { realpathSync } = await import('fs') + expect(result).toBe(realpathSync(realFile)) + }) +}) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts new file mode 100644 index 00000000..d161b369 --- /dev/null +++ b/src/relay/fs-handler.ts @@ -0,0 +1,276 @@ +import { + readdir, + readFile, + writeFile, + stat, + lstat, + mkdir, + rename, + cp, + rm, + realpath +} from 'fs/promises' +import { extname } from 'path' +import type { RelayDispatcher } from './dispatcher' +import type { RelayContext } from './context' +import { + MAX_FILE_SIZE, + DEFAULT_MAX_RESULTS, + IMAGE_MIME_TYPES, + isBinaryBuffer, + searchWithRg, + listFilesWithRg +} from './fs-handler-utils' + +type WatchState = { + rootPath: string + unwatchFn: (() => void) | null +} + +export class FsHandler { + private dispatcher: RelayDispatcher + private context: RelayContext + private watches = new Map() + + constructor(dispatcher: RelayDispatcher, context: RelayContext) { + this.dispatcher = dispatcher + this.context = context + this.registerHandlers() + } + + private registerHandlers(): void { + this.dispatcher.onRequest('fs.readDir', (p) => this.readDir(p)) + this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) + this.dispatcher.onRequest('fs.writeFile', (p) => this.writeFile(p)) + this.dispatcher.onRequest('fs.stat', (p) => this.stat(p)) + 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.rename', (p) => this.rename(p)) + this.dispatcher.onRequest('fs.copy', (p) => this.copy(p)) + this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) + this.dispatcher.onRequest('fs.search', (p) => this.search(p)) + this.dispatcher.onRequest('fs.listFiles', (p) => this.listFiles(p)) + this.dispatcher.onRequest('fs.watch', (p) => this.watch(p)) + this.dispatcher.onNotification('fs.unwatch', (p) => this.unwatch(p)) + } + + private async readDir(params: Record) { + const dirPath = params.dirPath as string + await this.context.validatePathResolved(dirPath) + const entries = await readdir(dirPath, { withFileTypes: true }) + return entries + .map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink() + })) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + } + + private async readFile(params: Record) { + const filePath = params.filePath as string + await this.context.validatePathResolved(filePath) + const stats = await stat(filePath) + if (stats.size > MAX_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + + const buffer = await readFile(filePath) + const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] + if (mimeType) { + return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } + } + + private async writeFile(params: Record) { + const filePath = params.filePath as string + await this.context.validatePathResolved(filePath) + const content = params.content as string + try { + const fileStats = await lstat(filePath) + if (fileStats.isDirectory()) { + throw new Error('Cannot write to a directory') + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + await writeFile(filePath, content, 'utf-8') + } + + private async stat(params: Record) { + const filePath = params.filePath as string + await this.context.validatePathResolved(filePath) + // Why: lstat is used instead of stat so that symlinks are reported as + // symlinks rather than being silently followed. stat() follows symlinks, + // meaning isSymbolicLink() would always return false. + const stats = await lstat(filePath) + let type: 'file' | 'directory' | 'symlink' = 'file' + if (stats.isDirectory()) { + type = 'directory' + } else if (stats.isSymbolicLink()) { + type = 'symlink' + } + return { size: stats.size, type, mtime: stats.mtimeMs } + } + + private async deletePath(params: Record) { + const targetPath = params.targetPath as string + await this.context.validatePathResolved(targetPath) + const recursive = params.recursive as boolean | undefined + const stats = await stat(targetPath) + if (stats.isDirectory() && !recursive) { + throw new Error('Cannot delete directory without recursive flag') + } + await rm(targetPath, { recursive: !!recursive, force: true }) + } + + private async createFile(params: Record) { + const filePath = params.filePath as string + // Why: symlinks in parent directories can redirect creation outside the + // workspace. validatePathResolved follows symlinks before checking roots. + await this.context.validatePathResolved(filePath) + const { dirname } = await import('path') + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) + } + + private async createDir(params: Record) { + const dirPath = params.dirPath as string + await this.context.validatePathResolved(dirPath) + await mkdir(dirPath, { recursive: true }) + } + + private async rename(params: Record) { + const oldPath = params.oldPath as string + const newPath = params.newPath as string + await this.context.validatePathResolved(oldPath) + await this.context.validatePathResolved(newPath) + await rename(oldPath, newPath) + } + + private async copy(params: Record) { + const source = params.source as string + const destination = params.destination as string + // Why: cp follows symlinks — a symlink inside the workspace pointing to + // /etc would copy sensitive files into the workspace where readFile can + // exfiltrate them. + await this.context.validatePathResolved(source) + await this.context.validatePathResolved(destination) + await cp(source, destination, { recursive: true }) + } + + private async realpath(params: Record) { + const filePath = params.filePath as string + this.context.validatePath(filePath) + const resolved = await realpath(filePath) + // Why: a symlink inside the workspace may resolve to a path outside it. + // Returning the resolved path without validation leaks the external target. + this.context.validatePath(resolved) + return resolved + } + + private async search(params: Record) { + const query = params.query as string + const rootPath = params.rootPath as string + // Why: a symlink inside the workspace pointing to a directory outside it + // would let rg search (and return content from) files beyond the workspace. + await this.context.validatePathResolved(rootPath) + const caseSensitive = params.caseSensitive as boolean | undefined + const wholeWord = params.wholeWord as boolean | undefined + const useRegex = params.useRegex as boolean | undefined + const includePattern = params.includePattern as string | undefined + const excludePattern = params.excludePattern as string | undefined + const maxResults = Math.min( + (params.maxResults as number) || DEFAULT_MAX_RESULTS, + DEFAULT_MAX_RESULTS + ) + + return searchWithRg(rootPath, query, { + caseSensitive, + wholeWord, + useRegex, + includePattern, + excludePattern, + maxResults + }) + } + + private async listFiles(params: Record): Promise { + const rootPath = params.rootPath as string + await this.context.validatePathResolved(rootPath) + return listFilesWithRg(rootPath) + } + + private async watch(params: Record) { + const rootPath = params.rootPath as string + this.context.validatePath(rootPath) + + if (this.watches.size >= 20) { + throw new Error('Maximum number of file watchers reached') + } + + if (this.watches.has(rootPath)) { + return + } + + const watchState: WatchState = { rootPath, unwatchFn: null } + this.watches.set(rootPath, watchState) + + try { + const watcher = await import('@parcel/watcher') + const subscription = await watcher.subscribe( + rootPath, + (err, events) => { + if (err) { + this.dispatcher.notify('fs.changed', { + events: [{ kind: 'overflow', absolutePath: rootPath }] + }) + return + } + const mapped = events.map((evt) => ({ + kind: evt.type, + absolutePath: evt.path + })) + this.dispatcher.notify('fs.changed', { events: mapped }) + }, + { ignore: ['.git', 'node_modules', 'dist', 'build', '.next', '.cache', '__pycache__'] } + ) + watchState.unwatchFn = () => { + void subscription.unsubscribe() + } + } catch { + // @parcel/watcher not available -- polling fallback would go here + process.stderr.write('[relay] File watcher not available, fs.changed events disabled\n') + } + } + + private unwatch(params: Record): void { + const rootPath = params.rootPath as string + const state = this.watches.get(rootPath) + if (state) { + state.unwatchFn?.() + this.watches.delete(rootPath) + } + } + + dispose(): void { + for (const [, state] of this.watches) { + state.unwatchFn?.() + } + this.watches.clear() + } +} diff --git a/src/relay/git-exec-validator.test.ts b/src/relay/git-exec-validator.test.ts new file mode 100644 index 00000000..ce168eed --- /dev/null +++ b/src/relay/git-exec-validator.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { validateGitExecArgs } from './git-exec-validator' + +function expectAllowed(args: string[]): void { + expect(() => validateGitExecArgs(args)).not.toThrow() +} + +function expectBlocked(args: string[], message: string): void { + expect(() => validateGitExecArgs(args)).toThrow(message) +} + +describe('validateGitExecArgs', () => { + describe('allowed read-only subcommands', () => { + it.each([ + [['rev-parse', '--show-toplevel']], + [['branch', '--list']], + [['log', '--oneline', '-10']], + [['show-ref', '--heads']], + [['ls-remote', 'origin']], + [['remote', '-v']], + [['remote', 'get-url', 'origin']], + [['remote', 'show', 'origin']], + [['symbolic-ref', 'HEAD']], + [['symbolic-ref', '--short', 'HEAD']], + [['merge-base', 'main', 'HEAD']], + [['ls-files', '--error-unmatch', 'foo.txt']], + [['config', '--get', 'user.name']], + [['config', '--get-all', 'remote.origin.url']], + [['config', '--list']], + [['config', '-l']], + [['config', '--get-regexp', 'user']] + ])('allows %j', (args) => { + expectAllowed(args) + }) + }) + + describe('blocked subcommands', () => { + it('rejects empty args', () => { + expectBlocked([], 'git subcommand not allowed: (empty)') + }) + + it.each([ + 'push', + 'pull', + 'commit', + 'checkout', + 'reset', + 'rebase', + 'merge', + 'stash', + 'clean', + 'gc', + 'reflog', + 'tag', + 'fetch', + 'worktree' + ])('rejects %s', (cmd) => { + expectBlocked([cmd], 'git subcommand not allowed') + }) + }) + + describe('global flags before subcommand', () => { + it.each([ + [['-c', 'core.sshCommand=evil', 'log']], + [['--no-pager', 'log']], + [['-C', '/tmp', 'status']] + ])('rejects %j', (args) => { + expectBlocked(args, 'Global git flags before the subcommand are not allowed') + }) + }) + + describe('global denied flags', () => { + it.each([ + [['log', '--output', '/tmp/leak']], + [['log', '--output=/tmp/leak']], + [['log', '-o', '/tmp/leak']], + [['rev-parse', '--exec-path=/evil']], + [['log', '--work-tree=/other']], + [['log', '--git-dir=/other/.git']] + ])('rejects %j', (args) => { + expectBlocked(args, 'Dangerous git flags are not allowed') + }) + + it('does not false-positive on unrelated =value flags', () => { + expectAllowed(['log', '--format=%H']) + expectAllowed(['log', '--pretty=oneline']) + }) + }) + + describe('git config', () => { + it('rejects config without read-only flag', () => { + expectBlocked(['config', 'user.name', 'Evil'], 'restricted to read-only operations') + }) + + it.each([ + ['--add'], + ['--unset'], + ['--unset-all'], + ['--replace-all'], + ['--rename-section'], + ['--remove-section'], + ['--edit'], + ['-e'], + ['--file=/etc/passwd'], + ['-f'], + ['--global'], + ['--system'] + ])('rejects config with write flag %s', (flag) => { + expectBlocked( + ['config', '--list', flag, 'val'], + 'git config write operations are not allowed' + ) + }) + }) + + describe('git branch', () => { + it('allows safe branch flags', () => { + expectAllowed(['branch', '--list']) + expectAllowed(['branch', '-a']) + expectAllowed(['branch', '-r']) + }) + + it.each(['-d', '-D', '--delete', '-m', '-M', '--move', '-c', '-C', '--copy'])( + 'rejects branch %s', + (flag) => { + expectBlocked(['branch', flag, 'name'], 'Destructive git branch flags') + } + ) + + it('catches --delete=value compound syntax', () => { + expectBlocked(['branch', '--delete=feature'], 'Destructive git branch flags') + }) + }) + + describe('git remote', () => { + it.each([ + 'add', + 'remove', + 'rm', + 'rename', + 'set-url', + 'set-head', + 'set-branches', + 'prune', + 'update' + ])('rejects remote %s', (subcmd) => { + expectBlocked(['remote', subcmd, 'arg'], 'Destructive git remote operations') + }) + + it('skips flags when finding remote subcommand', () => { + expectBlocked(['remote', '-v', 'add', 'evil', 'url'], 'Destructive git remote operations') + }) + }) + + describe('git symbolic-ref', () => { + it('allows read operations', () => { + expectAllowed(['symbolic-ref', 'HEAD']) + expectAllowed(['symbolic-ref', '--short', 'HEAD']) + expectAllowed(['symbolic-ref', '-q', 'HEAD']) + }) + + it.each(['-d', '--delete', '-m'])('rejects symbolic-ref %s', (flag) => { + expectBlocked(['symbolic-ref', flag, 'HEAD'], 'git symbolic-ref write operations') + }) + + it('rejects two positional args (write form)', () => { + expectBlocked( + ['symbolic-ref', 'HEAD', 'refs/heads/main'], + 'git symbolic-ref write operations' + ) + }) + + it('catches --delete=value compound syntax', () => { + expectBlocked(['symbolic-ref', '--delete=HEAD'], 'git symbolic-ref write operations') + }) + }) +}) diff --git a/src/relay/git-exec-validator.ts b/src/relay/git-exec-validator.ts new file mode 100644 index 00000000..fa22356c --- /dev/null +++ b/src/relay/git-exec-validator.ts @@ -0,0 +1,135 @@ +/** + * Git exec argument validation for the relay's git.exec handler. + * + * Why: oxlint max-lines requires files to stay under 300 lines. + * Extracted from git-handler-ops.ts to keep both files under the limit. + */ + +// Why: only read-only git subcommands are allowed via exec. config is restricted +// to read-only flags; branch rejects destructive flags; fetch/worktree removed. +const ALLOWED_GIT_SUBCOMMANDS = new Set([ + 'rev-parse', + 'branch', + 'log', + 'show-ref', + 'ls-remote', + 'remote', + 'symbolic-ref', + 'merge-base', + 'ls-files', + 'config' +]) +const CONFIG_READ_ONLY_FLAGS = new Set(['--get', '--get-all', '--list', '--get-regexp', '-l']) +// Why: checking presence of a read-only flag is insufficient — a request could +// include both --list (passes the check) and --add (performs a write). Reject +// known write operations explicitly. +const CONFIG_WRITE_FLAGS = new Set([ + '--add', + '--unset', + '--unset-all', + '--replace-all', + '--rename-section', + '--remove-section', + '--edit', + '-e', + // Why: --file redirects config reads to an arbitrary file, enabling path + // traversal (e.g. `--file /etc/passwd --list` leaks file contents). + '--file', + '-f', + '--global', + '--system' +]) +const BRANCH_DESTRUCTIVE_FLAGS = new Set([ + '-d', + '-D', + '--delete', + '-m', + '-M', + '--move', + '-c', + '-C', + '--copy' +]) + +// Why: these flags are dangerous across ALL subcommands — --output writes to +// arbitrary paths, --exec-path changes where git loads helpers from, --work-tree +// and --git-dir escape the validated worktree. +const GLOBAL_DENIED_FLAGS = new Set(['--output', '-o', '--exec-path', '--work-tree', '--git-dir']) + +const REMOTE_WRITE_SUBCOMMANDS = new Set([ + 'add', + 'remove', + 'rm', + 'rename', + 'set-head', + 'set-branches', + 'set-url', + 'prune', + 'update' +]) +const SYMBOLIC_REF_WRITE_FLAGS = new Set(['-d', '--delete', '-m']) + +// Why: git accepts --flag=value compound syntax (e.g. --file=/etc/passwd), +// which bypasses exact-match Set.has() checks. This helper catches both forms. +function matchesDeniedFlag(arg: string, denySet: Set): boolean { + if (denySet.has(arg)) { + return true + } + const eqIdx = arg.indexOf('=') + if (eqIdx > 0) { + return denySet.has(arg.slice(0, eqIdx)) + } + return false +} + +export function validateGitExecArgs(args: string[]): void { + // Why: git accepts `-c key=value` before the subcommand, which can override + // config and execute arbitrary commands (e.g. core.sshCommand). Reject any + // arguments before the subcommand that look like global git flags. + let subcommandIdx = 0 + while (subcommandIdx < args.length && args[subcommandIdx].startsWith('-')) { + subcommandIdx++ + } + if (subcommandIdx > 0) { + throw new Error('Global git flags before the subcommand are not allowed') + } + + const subcommand = args[0] + if (!subcommand || !ALLOWED_GIT_SUBCOMMANDS.has(subcommand)) { + throw new Error(`git subcommand not allowed: ${subcommand ?? '(empty)'}`) + } + const restArgs = args.slice(1) + + if (restArgs.some((a) => matchesDeniedFlag(a, GLOBAL_DENIED_FLAGS))) { + throw new Error('Dangerous git flags are not allowed via exec') + } + + if (subcommand === 'config') { + if (!restArgs.some((a) => CONFIG_READ_ONLY_FLAGS.has(a))) { + throw new Error('git config is restricted to read-only operations (--get, --list, etc.)') + } + if (restArgs.some((a) => matchesDeniedFlag(a, CONFIG_WRITE_FLAGS))) { + throw new Error('git config write operations are not allowed via exec') + } + } + if (subcommand === 'branch') { + if (restArgs.some((a) => matchesDeniedFlag(a, BRANCH_DESTRUCTIVE_FLAGS))) { + throw new Error('Destructive git branch flags are not allowed via exec') + } + } + if (subcommand === 'remote') { + const remoteSubcmd = restArgs.find((a) => !a.startsWith('-')) + if (remoteSubcmd && REMOTE_WRITE_SUBCOMMANDS.has(remoteSubcmd)) { + throw new Error('Destructive git remote operations are not allowed via exec') + } + } + if (subcommand === 'symbolic-ref') { + if (restArgs.some((a) => matchesDeniedFlag(a, SYMBOLIC_REF_WRITE_FLAGS))) { + throw new Error('git symbolic-ref write operations are not allowed via exec') + } + const positionalArgs = restArgs.filter((a) => !a.startsWith('-')) + if (positionalArgs.length >= 2) { + throw new Error('git symbolic-ref write operations are not allowed via exec') + } + } +} diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts new file mode 100644 index 00000000..b437ff44 --- /dev/null +++ b/src/relay/git-handler-ops.ts @@ -0,0 +1,266 @@ +/** + * Higher-level git operations extracted from git-handler.ts. + * + * Why: oxlint max-lines requires files to stay under 300 lines. + * These async operations accept a git executor callback so they + * remain decoupled from the GitHandler class. + */ +import * as path from 'path' +import { readFile } from 'fs/promises' +import { bufferToBlob, buildDiffResult, parseBranchDiff } from './git-handler-utils' + +// ─── Executor types ────────────────────────────────────────────────── + +export type GitExec = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> + +export type GitBufferExec = (args: string[], cwd: string) => Promise + +// ─── Blob reading ──────────────────────────────────────────────────── + +export async function readBlobAtOid( + gitBuffer: GitBufferExec, + cwd: string, + oid: string, + filePath: string +): Promise<{ content: string; isBinary: boolean }> { + try { + const buf = await gitBuffer(['show', `${oid}:${filePath}`], cwd) + return bufferToBlob(buf, filePath) + } catch { + return { content: '', isBinary: false } + } +} + +export async function readBlobAtIndex( + gitBuffer: GitBufferExec, + cwd: string, + filePath: string +): Promise<{ content: string; isBinary: boolean }> { + try { + const buf = await gitBuffer(['show', `:${filePath}`], cwd) + return bufferToBlob(buf, filePath) + } catch { + return { content: '', isBinary: false } + } +} + +export async function readUnstagedLeft( + gitBuffer: GitBufferExec, + cwd: string, + filePath: string +): Promise<{ content: string; isBinary: boolean }> { + const index = await readBlobAtIndex(gitBuffer, cwd, filePath) + if (index.content || index.isBinary) { + return index + } + return readBlobAtOid(gitBuffer, cwd, 'HEAD', filePath) +} + +export async function readWorkingFile( + absPath: string +): Promise<{ content: string; isBinary: boolean }> { + try { + const buffer = await readFile(absPath) + return bufferToBlob(buffer) + } catch { + return { content: '', isBinary: false } + } +} + +// ─── Diff ──────────────────────────────────────────────────────────── + +export async function computeDiff( + git: GitBufferExec, + worktreePath: string, + filePath: string, + staged: boolean +) { + let originalContent = '' + let modifiedContent = '' + let originalIsBinary = false + let modifiedIsBinary = false + + try { + if (staged) { + const left = await readBlobAtOid(git, worktreePath, 'HEAD', filePath) + originalContent = left.content + originalIsBinary = left.isBinary + + const right = await readBlobAtIndex(git, worktreePath, filePath) + modifiedContent = right.content + modifiedIsBinary = right.isBinary + } else { + const left = await readUnstagedLeft(git, worktreePath, filePath) + originalContent = left.content + originalIsBinary = left.isBinary + + const right = await readWorkingFile(path.join(worktreePath, filePath)) + modifiedContent = right.content + modifiedIsBinary = right.isBinary + } + } catch { + // Fallback to empty + } + + return buildDiffResult( + originalContent, + modifiedContent, + originalIsBinary, + modifiedIsBinary, + filePath + ) +} + +// ─── Branch compare ────────────────────────────────────────────────── + +export async function branchCompare( + git: GitExec, + worktreePath: string, + baseRef: string, + loadBranchChanges: (mergeBase: string, headOid: string) => Promise[]> +) { + const summary: Record = { + baseRef, + baseOid: null, + compareRef: 'HEAD', + headOid: null, + mergeBase: null, + changedFiles: 0, + status: 'loading' + } + + try { + const { stdout: branchOut } = await git(['branch', '--show-current'], worktreePath) + const branch = branchOut.trim() + if (branch) { + summary.compareRef = branch + } + } catch { + /* keep HEAD */ + } + + let headOid: string + try { + const { stdout } = await git(['rev-parse', '--verify', 'HEAD'], worktreePath) + headOid = stdout.trim() + summary.headOid = headOid + } catch { + summary.status = 'unborn-head' + summary.errorMessage = + 'This branch does not have a committed HEAD yet, so compare-to-base is unavailable.' + return { summary, entries: [] } + } + + let baseOid: string + try { + const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath) + baseOid = stdout.trim() + summary.baseOid = baseOid + } catch { + summary.status = 'invalid-base' + summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.` + return { summary, entries: [] } + } + + let mergeBase: string + try { + const { stdout } = await git(['merge-base', baseOid, headOid], worktreePath) + mergeBase = stdout.trim() + summary.mergeBase = mergeBase + } catch { + summary.status = 'no-merge-base' + summary.errorMessage = `This branch and ${baseRef} do not share a merge base, so compare-to-base is unavailable.` + return { summary, entries: [] } + } + + try { + const entries = await loadBranchChanges(mergeBase, headOid) + const { stdout: countOut } = await git( + ['rev-list', '--count', `${baseOid}..${headOid}`], + worktreePath + ) + summary.changedFiles = entries.length + summary.commitsAhead = parseInt(countOut.trim(), 10) || 0 + summary.status = 'ready' + return { summary, entries } + } catch (error) { + summary.status = 'error' + summary.errorMessage = error instanceof Error ? error.message : 'Failed to load branch compare' + return { summary, entries: [] } + } +} + +// ─── Branch diff ───────────────────────────────────────────────────── + +export async function branchDiffEntries( + git: GitExec, + gitBuffer: GitBufferExec, + worktreePath: string, + baseRef: string, + opts: { includePatch?: boolean; filePath?: string; oldPath?: string } +) { + let headOid: string + let mergeBase: string + try { + const { stdout: headOut } = await git(['rev-parse', '--verify', 'HEAD'], worktreePath) + headOid = headOut.trim() + + const { stdout: baseOut } = await git(['rev-parse', '--verify', baseRef], worktreePath) + const baseOid = baseOut.trim() + + const { stdout: mbOut } = await git(['merge-base', baseOid, headOid], worktreePath) + mergeBase = mbOut.trim() + } catch { + return [] + } + + const { stdout } = await git( + ['diff', '--name-status', '-M', '-C', mergeBase, headOid], + worktreePath + ) + const allChanges = parseBranchDiff(stdout) + + // Why: the IPC handler for single-file branch diff sends filePath/oldPath + // to avoid reading blobs for every changed file — only the matched file. + let changes = allChanges + if (opts.filePath) { + changes = allChanges.filter( + (c) => + c.path === opts.filePath || + c.oldPath === opts.filePath || + (opts.oldPath && (c.path === opts.oldPath || c.oldPath === opts.oldPath)) + ) + } + + if (!opts.includePatch) { + return changes.map(() => ({ + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + })) + } + + const results: Record[] = [] + for (const change of changes) { + const fp = change.path as string + const oldP = (change.oldPath as string) ?? fp + try { + const left = await readBlobAtOid(gitBuffer, worktreePath, mergeBase, oldP) + const right = await readBlobAtOid(gitBuffer, worktreePath, headOid, fp) + results.push(buildDiffResult(left.content, right.content, left.isBinary, right.isBinary, fp)) + } catch { + results.push({ + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + }) + } + } + return results +} + +export { validateGitExecArgs } from './git-exec-validator' diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts new file mode 100644 index 00000000..292ee494 --- /dev/null +++ b/src/relay/git-handler-utils.ts @@ -0,0 +1,324 @@ +/** + * Pure parsing helpers extracted from git-handler.ts. + * + * Why: oxlint max-lines requires files to stay under 300 lines. + * These functions have no side-effects and depend only on their arguments, + * making them easy to test independently. + */ +import * as path from 'path' +import { existsSync } from 'fs' + +// ─── Status parsing ────────────────────────────────────────────────── + +export function parseStatusChar(char: string): string { + switch (char) { + case 'M': + return 'modified' + case 'A': + return 'added' + case 'D': + return 'deleted' + case 'R': + return 'renamed' + case 'C': + return 'copied' + default: + return 'modified' + } +} + +export function parseBranchStatusChar(char: string): string { + switch (char) { + case 'M': + return 'modified' + case 'A': + return 'added' + case 'D': + return 'deleted' + case 'R': + return 'renamed' + case 'C': + return 'copied' + default: + return 'modified' + } +} + +export function parseConflictKind(xy: string): string | null { + switch (xy) { + case 'UU': + return 'both_modified' + case 'AA': + return 'both_added' + case 'DD': + return 'both_deleted' + case 'AU': + return 'added_by_us' + case 'UA': + return 'added_by_them' + case 'DU': + return 'deleted_by_us' + case 'UD': + return 'deleted_by_them' + default: + return null + } +} + +/** + * Parse `git status --porcelain=v2` output into structured entries. + * Does NOT handle unmerged entries (those require worktree access). + */ +export function parseStatusOutput(stdout: string): { + entries: Record[] + unmergedLines: string[] +} { + const entries: Record[] = [] + const unmergedLines: string[] = [] + + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + + if (line.startsWith('1 ') || line.startsWith('2 ')) { + const parts = line.split(' ') + const xy = parts[1] + const indexStatus = xy[0] + const worktreeStatus = xy[1] + + if (line.startsWith('2 ')) { + // Why: porcelain v2 type-2 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`. + // The new path is the last space-delimited token before the tab; origPath follows the tab. + const tabParts = line.split('\t') + const spaceParts = tabParts[0].split(' ') + const filePath = spaceParts.at(-1)! + const oldPath = tabParts[1] + if (indexStatus !== '.') { + entries.push({ + path: filePath, + status: parseStatusChar(indexStatus), + area: 'staged', + oldPath + }) + } + if (worktreeStatus !== '.') { + entries.push({ + path: filePath, + status: parseStatusChar(worktreeStatus), + area: 'unstaged', + oldPath + }) + } + } else { + const filePath = parts.slice(8).join(' ') + if (indexStatus !== '.') { + entries.push({ path: filePath, status: parseStatusChar(indexStatus), area: 'staged' }) + } + if (worktreeStatus !== '.') { + entries.push({ + path: filePath, + status: parseStatusChar(worktreeStatus), + area: 'unstaged' + }) + } + } + } else if (line.startsWith('? ')) { + entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' }) + } else if (line.startsWith('u ')) { + unmergedLines.push(line) + } + } + + return { entries, unmergedLines } +} + +/** + * Parse a single unmerged entry line from porcelain v2 output. + * Returns null if the entry should be skipped (e.g. submodule conflicts). + */ +export function parseUnmergedEntry( + worktreePath: string, + line: string +): Record | null { + const parts = line.split(' ') + const xy = parts[1] + const modeStage1 = parts[3] + const modeStage2 = parts[4] + const modeStage3 = parts[5] + const filePath = parts.slice(10).join(' ') + if (!filePath) { + return null + } + + // Skip submodule conflicts (mode 160000) + if ([modeStage1, modeStage2, modeStage3].some((m) => m === '160000')) { + return null + } + + const conflictKind = parseConflictKind(xy) + if (!conflictKind) { + return null + } + + let status: string = 'modified' + if (conflictKind === 'both_deleted') { + status = 'deleted' + } else if (conflictKind !== 'both_modified' && conflictKind !== 'both_added') { + try { + status = existsSync(path.join(worktreePath, filePath)) ? 'modified' : 'deleted' + } catch { + // Why: defaulting to 'modified' on fs error is the least misleading option + status = 'modified' + } + } + + return { + path: filePath, + area: 'unstaged', + status, + conflictKind, + conflictStatus: 'unresolved' + } +} + +// ─── Branch diff parsing ───────────────────────────────────────────── + +/** + * Parse `git diff --name-status` output into structured change entries. + */ +export function parseBranchDiff(stdout: string): Record[] { + const entries: Record[] = [] + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + const parts = line.split('\t') + const rawStatus = parts[0] ?? '' + const status = parseBranchStatusChar(rawStatus[0] ?? 'M') + + if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) { + const oldPath = parts[1] + const filePath = parts[2] + if (filePath) { + entries.push({ path: filePath, oldPath, status }) + } + } else { + const filePath = parts[1] + if (filePath) { + entries.push({ path: filePath, status }) + } + } + } + return entries +} + +// ─── Worktree parsing ──────────────────────────────────────────────── + +export function parseWorktreeList(output: string): Record[] { + const worktrees: Record[] = [] + const blocks = output.trim().split(/\r?\n\r?\n/) + + for (const block of blocks) { + if (!block.trim()) { + continue + } + const lines = block.trim().split(/\r?\n/) + let wtPath = '' + let head = '' + let branch = '' + let isBare = false + + for (const line of lines) { + if (line.startsWith('worktree ')) { + wtPath = line.slice('worktree '.length) + } else if (line.startsWith('HEAD ')) { + head = line.slice('HEAD '.length) + } else if (line.startsWith('branch ')) { + branch = line.slice('branch '.length) + } else if (line === 'bare') { + isBare = true + } + } + + if (wtPath) { + worktrees.push({ + path: wtPath, + head, + branch, + isBare, + isMainWorktree: worktrees.length === 0 + }) + } + } + return worktrees +} + +// ─── Binary / blob helpers ─────────────────────────────────────────── + +export function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, 8192) + for (let i = 0; i < len; i++) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +export const PREVIEWABLE_MIME: 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 function bufferToBlob( + buffer: Buffer, + filePath?: string +): { content: string; isBinary: boolean } { + const binary = isBinaryBuffer(buffer) + if (binary) { + const ext = filePath ? path.extname(filePath).toLowerCase() : '' + const previewable = !!PREVIEWABLE_MIME[ext] + return { content: previewable ? buffer.toString('base64') : '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } +} + +/** + * Build a diff result object from original/modified content. + * Used by both working-tree diffs and branch diffs. + */ +export function buildDiffResult( + originalContent: string, + modifiedContent: string, + originalIsBinary: boolean, + modifiedIsBinary: boolean, + filePath?: string +) { + if (originalIsBinary || modifiedIsBinary) { + const ext = filePath ? path.extname(filePath).toLowerCase() : '' + const mimeType = PREVIEWABLE_MIME[ext] + return { + kind: 'binary' as const, + originalContent, + modifiedContent, + originalIsBinary, + modifiedIsBinary, + ...(mimeType ? { isImage: true, mimeType } : {}) + } + } + return { + kind: 'text' as const, + originalContent, + modifiedContent, + originalIsBinary: false, + modifiedIsBinary: false + } +} diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts new file mode 100644 index 00000000..1cf6ff16 --- /dev/null +++ b/src/relay/git-handler.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { GitHandler } from './git-handler' +import { RelayContext } from './context' +import type { RelayDispatcher } from './dispatcher' +import * as fs from 'fs/promises' +import * as path from 'path' +import { mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { execFileSync } from 'child_process' + +function createMockDispatcher() { + const requestHandlers = new Map) => Promise>() + const notificationHandlers = new Map) => void>() + + return { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ), + onNotification: vi.fn((method: string, handler: (params: Record) => void) => { + notificationHandlers.set(method, handler) + }), + notify: vi.fn(), + _requestHandlers: requestHandlers, + async callRequest(method: string, params: Record = {}) { + const handler = requestHandlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + return handler(params) + } + } +} + +function gitInit(dir: string): void { + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' }) +} + +function gitCommit(dir: string, message: string): void { + execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' }) +} + +describe('GitHandler', () => { + let dispatcher: ReturnType + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-')) + dispatcher = createMockDispatcher() + const ctx = new RelayContext() + ctx.registerRoot(tmpDir) + new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it('registers all expected handlers', () => { + const methods = Array.from(dispatcher._requestHandlers.keys()) + expect(methods).toContain('git.status') + expect(methods).toContain('git.diff') + expect(methods).toContain('git.stage') + expect(methods).toContain('git.unstage') + expect(methods).toContain('git.bulkStage') + expect(methods).toContain('git.bulkUnstage') + expect(methods).toContain('git.discard') + expect(methods).toContain('git.conflictOperation') + expect(methods).toContain('git.branchCompare') + expect(methods).toContain('git.branchDiff') + expect(methods).toContain('git.listWorktrees') + expect(methods).toContain('git.addWorktree') + expect(methods).toContain('git.removeWorktree') + }) + + describe('status', () => { + it('returns empty entries for clean repo', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') + gitCommit(tmpDir, 'initial') + + const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { + entries: Record[] + conflictOperation: string + } + expect(result.entries).toEqual([]) + expect(result.conflictOperation).toBe('unknown') + }) + + it('detects untracked files', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'tracked.txt'), 'tracked') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'new.txt'), 'new') + + const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { + entries: Record[] + } + const untracked = result.entries.find((e) => e.path === 'new.txt') + expect(untracked).toBeDefined() + expect(untracked!.status).toBe('untracked') + expect(untracked!.area).toBe('untracked') + }) + + it('detects modified files', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'original') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') + + const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { + entries: Record[] + } + const modified = result.entries.find((e) => e.path === 'file.txt') + expect(modified).toBeDefined() + expect(modified!.status).toBe('modified') + expect(modified!.area).toBe('unstaged') + }) + + it('detects staged files', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'original') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') + execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) + + const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { + entries: Record[] + } + const staged = result.entries.find((e) => e.area === 'staged') + expect(staged).toBeDefined() + expect(staged!.status).toBe('modified') + }) + }) + + describe('stage and unstage', () => { + it('stages a file', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'content') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') + + await dispatcher.callRequest('git.stage', { worktreePath: tmpDir, filePath: 'file.txt' }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output.trim()).toBe('file.txt') + }) + + it('unstages a file', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'content') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') + execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) + + await dispatcher.callRequest('git.unstage', { worktreePath: tmpDir, filePath: 'file.txt' }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output.trim()).toBe('') + }) + }) + + describe('diff', () => { + it('returns text diff for modified file', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'original') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') + + const result = (await dispatcher.callRequest('git.diff', { + worktreePath: tmpDir, + filePath: 'file.txt', + staged: false + })) as { kind: string; originalContent: string; modifiedContent: string } + expect(result.kind).toBe('text') + expect(result.originalContent).toBe('original') + expect(result.modifiedContent).toBe('modified') + }) + + it('returns staged diff', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'original') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'staged-content') + execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) + + const result = (await dispatcher.callRequest('git.diff', { + worktreePath: tmpDir, + filePath: 'file.txt', + staged: true + })) as { kind: string; originalContent: string; modifiedContent: string } + expect(result.kind).toBe('text') + expect(result.originalContent).toBe('original') + expect(result.modifiedContent).toBe('staged-content') + }) + }) + + describe('discard', () => { + it('discards changes to tracked file', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'original') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') + + await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: 'file.txt' }) + + const content = await fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8') + expect(content).toBe('original') + }) + + it('deletes untracked file on discard', async () => { + gitInit(tmpDir) + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'new.txt'), 'untracked') + + await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: 'new.txt' }) + await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow() + }) + + it('rejects path traversal', async () => { + gitInit(tmpDir) + await expect( + dispatcher.callRequest('git.discard', { + worktreePath: tmpDir, + filePath: '../../../etc/passwd' + }) + ).rejects.toThrow('outside the worktree') + }) + }) + + describe('conflictOperation', () => { + it('returns unknown for normal repo', async () => { + gitInit(tmpDir) + gitCommit(tmpDir, 'initial') + + const result = await dispatcher.callRequest('git.conflictOperation', { worktreePath: tmpDir }) + expect(result).toBe('unknown') + }) + }) + + describe('branchCompare', () => { + it('compares branch against base', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + + execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature') + gitCommit(tmpDir, 'feature commit') + + const result = (await dispatcher.callRequest('git.branchCompare', { + worktreePath: tmpDir, + baseRef: 'master' + })) as { summary: Record; entries: Record[] } + + // May be 'master' or error if default branch is 'main' + if (result.summary.status === 'ready') { + expect(result.entries.length).toBeGreaterThan(0) + expect(result.summary.commitsAhead).toBe(1) + } + }) + }) + + describe('listWorktrees', () => { + it('lists worktrees for a repo', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') + gitCommit(tmpDir, 'initial') + + const result = (await dispatcher.callRequest('git.listWorktrees', { + repoPath: tmpDir + })) as Record[] + expect(result.length).toBeGreaterThanOrEqual(1) + expect(result[0].isMainWorktree).toBe(true) + }) + }) + + describe('bulkStage and bulkUnstage', () => { + it('stages multiple files', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'a.txt'), 'a') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b') + gitCommit(tmpDir, 'initial') + + writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified') + + await dispatcher.callRequest('git.bulkStage', { + worktreePath: tmpDir, + filePaths: ['a.txt', 'b.txt'] + }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output).toContain('a.txt') + expect(output).toContain('b.txt') + }) + + it('unstages multiple files', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'a.txt'), 'a') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b') + gitCommit(tmpDir, 'initial') + + writeFileSync(path.join(tmpDir, 'a.txt'), 'changed') + writeFileSync(path.join(tmpDir, 'b.txt'), 'changed') + execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) + + await dispatcher.callRequest('git.bulkUnstage', { + worktreePath: tmpDir, + filePaths: ['a.txt', 'b.txt'] + }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output.trim()).toBe('') + }) + }) +}) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts new file mode 100644 index 00000000..d5e9f280 --- /dev/null +++ b/src/relay/git-handler.ts @@ -0,0 +1,348 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' +import { existsSync } from 'fs' +import { readFile, rm } from 'fs/promises' +import * as path from 'path' +import type { RelayDispatcher } from './dispatcher' +import type { RelayContext } from './context' +import { + parseStatusOutput, + parseUnmergedEntry, + parseBranchDiff, + parseWorktreeList +} from './git-handler-utils' +import { + computeDiff, + branchCompare as branchCompareOp, + branchDiffEntries, + validateGitExecArgs +} from './git-handler-ops' + +const execFileAsync = promisify(execFile) +const MAX_GIT_BUFFER = 10 * 1024 * 1024 +const BULK_CHUNK_SIZE = 100 + +export class GitHandler { + private dispatcher: RelayDispatcher + private context: RelayContext + + constructor(dispatcher: RelayDispatcher, context: RelayContext) { + this.dispatcher = dispatcher + this.context = context + this.registerHandlers() + } + + private registerHandlers(): void { + this.dispatcher.onRequest('git.status', (p) => this.getStatus(p)) + this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p)) + this.dispatcher.onRequest('git.stage', (p) => this.stage(p)) + this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p)) + this.dispatcher.onRequest('git.bulkStage', (p) => this.bulkStage(p)) + this.dispatcher.onRequest('git.bulkUnstage', (p) => this.bulkUnstage(p)) + this.dispatcher.onRequest('git.discard', (p) => this.discard(p)) + this.dispatcher.onRequest('git.conflictOperation', (p) => this.conflictOperation(p)) + this.dispatcher.onRequest('git.branchCompare', (p) => this.branchCompare(p)) + this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) + this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p)) + this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) + this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) + this.dispatcher.onRequest('git.exec', (p) => this.exec(p)) + this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p)) + } + + private async git( + args: string[], + cwd: string, + opts?: { maxBuffer?: number } + ): Promise<{ stdout: string; stderr: string }> { + return execFileAsync('git', args, { + cwd, + encoding: 'utf-8', + maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER + }) + } + + private async gitBuffer(args: string[], cwd: string): Promise { + const { stdout } = (await execFileAsync('git', args, { + cwd, + encoding: 'buffer', + maxBuffer: MAX_GIT_BUFFER + })) as { stdout: Buffer } + return stdout + } + + private async getStatus(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const conflictOperation = await this.detectConflictOperation(worktreePath) + const entries: Record[] = [] + + try { + const { stdout } = await this.git( + ['status', '--porcelain=v2', '--untracked-files=all'], + worktreePath + ) + + const parsed = parseStatusOutput(stdout) + entries.push(...parsed.entries) + + for (const uLine of parsed.unmergedLines) { + const entry = parseUnmergedEntry(worktreePath, uLine) + if (entry) { + entries.push(entry) + } + } + } catch { + // Not a git repo or git not available + } + + return { entries, conflictOperation } + } + + private async detectConflictOperation(worktreePath: string): Promise { + const gitDir = await this.resolveGitDir(worktreePath) + try { + if (existsSync(path.join(gitDir, 'MERGE_HEAD'))) { + return 'merge' + } + if ( + existsSync(path.join(gitDir, 'rebase-merge')) || + existsSync(path.join(gitDir, 'rebase-apply')) + ) { + return 'rebase' + } + if (existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) { + return 'cherry-pick' + } + } catch { + // fs error + } + return 'unknown' + } + + private async resolveGitDir(worktreePath: string): Promise { + const dotGitPath = path.join(worktreePath, '.git') + try { + const contents = await readFile(dotGitPath, 'utf-8') + const match = contents.match(/^gitdir:\s*(.+)\s*$/m) + if (match) { + return path.resolve(worktreePath, match[1]) + } + } catch { + // .git is a directory + } + return dotGitPath + } + + private async getDiff(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const filePath = params.filePath as string + // Why: filePath is relative to worktreePath and used in readWorkingFile via + // path.join. Without validation, ../../etc/passwd traverses outside the worktree. + const resolved = path.resolve(worktreePath, filePath) + const rel = path.relative(path.resolve(worktreePath), resolved) + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error(`Path "${filePath}" resolves outside the worktree`) + } + const staged = params.staged as boolean + return computeDiff(this.gitBuffer.bind(this), worktreePath, filePath, staged) + } + + private async stage(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const filePath = params.filePath as string + await this.git(['add', '--', filePath], worktreePath) + } + + private async unstage(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const filePath = params.filePath as string + await this.git(['restore', '--staged', '--', filePath], worktreePath) + } + + private async bulkStage(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const filePaths = params.filePaths as string[] + for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) + await this.git(['add', '--', ...chunk], worktreePath) + } + } + + private async bulkUnstage(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const filePaths = params.filePaths as string[] + for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) + await this.git(['restore', '--staged', '--', ...chunk], worktreePath) + } + } + + private async discard(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const filePath = params.filePath as string + + const resolved = path.resolve(worktreePath, filePath) + const rel = path.relative(path.resolve(worktreePath), resolved) + // Why: empty rel or '.' means the path IS the worktree root — rm -rf would + // delete the entire worktree. Reject along with parent-escaping paths. + if (!rel || rel === '.' || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) { + throw new Error(`Path "${filePath}" resolves outside the worktree`) + } + + let tracked = false + try { + await this.git(['ls-files', '--error-unmatch', '--', filePath], worktreePath) + tracked = true + } catch { + // untracked + } + + if (tracked) { + await this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath) + } else { + // Why: textual path checks pass for symlinks inside the worktree, but + // rm follows symlinks — so a symlink pointing outside the workspace + // would delete the target. validatePathResolved catches this. + await this.context.validatePathResolved(resolved) + await rm(resolved, { force: true, recursive: true }) + } + } + + private async conflictOperation(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + return this.detectConflictOperation(worktreePath) + } + + private async branchCompare(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const baseRef = params.baseRef as string + // Why: a baseRef starting with '-' would be interpreted as a flag to + // git rev-parse, potentially leaking environment variables or config. + if (baseRef.startsWith('-')) { + throw new Error('Base ref must not start with "-"') + } + const gitBound = this.git.bind(this) + return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => { + const { stdout } = await gitBound( + ['diff', '--name-status', '-M', '-C', mergeBase, headOid], + worktreePath + ) + return parseBranchDiff(stdout) + }) + } + + private async branchDiff(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const baseRef = params.baseRef as string + if (baseRef.startsWith('-')) { + throw new Error('Base ref must not start with "-"') + } + return branchDiffEntries( + this.git.bind(this), + this.gitBuffer.bind(this), + worktreePath, + baseRef, + { + includePatch: params.includePatch as boolean | undefined, + filePath: params.filePath as string | undefined, + oldPath: params.oldPath as string | undefined + } + ) + } + + private async exec(params: Record) { + const args = params.args as string[] + const cwd = params.cwd as string + this.context.validatePath(cwd) + + validateGitExecArgs(args) + const { stdout, stderr } = await this.git(args, cwd) + return { stdout, stderr } + } + + // Why: isGitRepo is called during the add-repo flow before any workspace + // roots are registered with the relay. Skipping validatePath is safe because + // this is a read-only git rev-parse check — no files are mutated. + private async isGitRepo(params: Record) { + const dirPath = params.dirPath as string + try { + const { stdout } = await this.git(['rev-parse', '--show-toplevel'], dirPath) + return { isRepo: true, rootPath: stdout.trim() } + } catch { + return { isRepo: false, rootPath: null } + } + } + + private async listWorktrees(params: Record) { + const repoPath = params.repoPath as string + this.context.validatePath(repoPath) + try { + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath) + return parseWorktreeList(stdout) + } catch { + return [] + } + } + + private async addWorktree(params: Record) { + const repoPath = params.repoPath as string + this.context.validatePath(repoPath) + const branchName = params.branchName as string + const targetDir = params.targetDir as string + this.context.validatePath(targetDir) + const base = params.base as string | undefined + const track = params.track as boolean | undefined + + // Why: a branchName starting with '-' would be interpreted as a git flag, + // potentially changing the command's semantics (e.g. "--detach"). + if (branchName.startsWith('-') || (base && base.startsWith('-'))) { + throw new Error('Branch name and base ref must not start with "-"') + } + + const args = ['worktree', 'add'] + if (track) { + args.push('--track') + } + args.push('-b', branchName, targetDir) + if (base) { + args.push(base) + } + + await this.git(args, repoPath) + } + + private async removeWorktree(params: Record) { + const worktreePath = params.worktreePath as string + this.context.validatePath(worktreePath) + const force = params.force as boolean | undefined + + let repoPath = worktreePath + try { + const { stdout } = await this.git(['rev-parse', '--git-common-dir'], worktreePath) + const commonDir = stdout.trim() + if (commonDir && commonDir !== '.git') { + repoPath = path.resolve(worktreePath, commonDir, '..') + } + } catch { + // Fall through with worktreePath as repo + } + + const args = ['worktree', 'remove'] + if (force) { + args.push('--force') + } + args.push(worktreePath) + await this.git(args, repoPath) + await this.git(['worktree', 'prune'], repoPath) + } +} diff --git a/src/relay/integration.test.ts b/src/relay/integration.test.ts new file mode 100644 index 00000000..50b167a0 --- /dev/null +++ b/src/relay/integration.test.ts @@ -0,0 +1,403 @@ +/** + * End-to-end in-process integration test. + * + * Wires the client-side SshChannelMultiplexer directly to the relay-side + * RelayDispatcher through an in-memory pipe — no SSH, no subprocess. + * Validates the full JSON-RPC roundtrip: client request → framing → + * relay decode → handler → response → framing → client decode → result. + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync } from 'fs' +import { rm, readFile, stat } from 'fs/promises' +import * as path from 'path' +import { tmpdir } from 'os' +import { execFileSync } from 'child_process' + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' + +import { RelayDispatcher } from './dispatcher' +import { RelayContext } from './context' +import { FsHandler } from './fs-handler' +import { GitHandler } from './git-handler' + +function gitInit(dir: string): void { + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' }) +} + +function gitCommit(dir: string, message: string): void { + execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', message], { cwd: dir, stdio: 'pipe' }) +} + +describe('Integration: Client Mux ↔ Relay Dispatcher', () => { + let tmpDir: string + let mux: SshChannelMultiplexer + let dispatcher: RelayDispatcher + let fsHandler: FsHandler + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-e2e-')) + + // Build the in-memory pipe + let relayFeedFn: (data: Buffer) => void + + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + const clientCloseCallbacks: (() => void)[] = [] + + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + // Client → Relay + setImmediate(() => relayFeedFn?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: (cb) => { + clientCloseCallbacks.push(cb) + } + } + + // Relay side + dispatcher = new RelayDispatcher((data: Buffer) => { + // Relay → Client + setImmediate(() => { + for (const cb of clientDataCallbacks) { + cb(data) + } + }) + }) + + relayFeedFn = (data: Buffer) => dispatcher.feed(data) + + // Register handlers on the relay + const context = new RelayContext() + context.registerRoot(tmpDir) + fsHandler = new FsHandler(dispatcher, context) + new GitHandler(dispatcher, context) + + // Create client mux + mux = new SshChannelMultiplexer(clientTransport) + }) + + afterEach(async () => { + mux.dispose() + dispatcher.dispose() + fsHandler.dispose() + await rm(tmpDir, { recursive: true, force: true }) + }) + + // ─── Filesystem ───────────────────────────────────────────────── + + describe('Filesystem operations', () => { + it('readDir returns directory entries', async () => { + writeFileSync(path.join(tmpDir, 'hello.txt'), 'world') + writeFileSync(path.join(tmpDir, 'readme.md'), '# Hi') + + const result = (await mux.request('fs.readDir', { dirPath: tmpDir })) as { + name: string + isDirectory: boolean + isSymlink: boolean + }[] + + expect(result.length).toBe(2) + const names = result.map((e) => e.name).sort() + expect(names).toEqual(['hello.txt', 'readme.md']) + }) + + it('readFile returns text content', async () => { + writeFileSync(path.join(tmpDir, 'data.txt'), 'some content') + + const result = (await mux.request('fs.readFile', { + filePath: path.join(tmpDir, 'data.txt') + })) as { content: string; isBinary: boolean } + + expect(result.content).toBe('some content') + expect(result.isBinary).toBe(false) + }) + + it('writeFile creates/overwrites file content', async () => { + const filePath = path.join(tmpDir, 'output.txt') + + await mux.request('fs.writeFile', { filePath, content: 'written via relay' }) + + const content = await readFile(filePath, 'utf-8') + expect(content).toBe('written via relay') + }) + + it('stat returns file metadata', async () => { + writeFileSync(path.join(tmpDir, 'sized.txt'), 'abcdef') + + const result = (await mux.request('fs.stat', { + filePath: path.join(tmpDir, 'sized.txt') + })) as { size: number; type: string; mtime: number } + + expect(result.type).toBe('file') + expect(result.size).toBe(6) + expect(typeof result.mtime).toBe('number') + }) + + it('createFile + deletePath roundtrip', async () => { + const filePath = path.join(tmpDir, 'nested', 'deep', 'new.txt') + + await mux.request('fs.createFile', { filePath }) + const s = await stat(filePath) + expect(s.isFile()).toBe(true) + + await mux.request('fs.deletePath', { targetPath: filePath }) + await expect(stat(filePath)).rejects.toThrow() + }) + + it('createDir creates directories recursively', async () => { + const dirPath = path.join(tmpDir, 'a', 'b', 'c') + + await mux.request('fs.createDir', { dirPath }) + const s = await stat(dirPath) + expect(s.isDirectory()).toBe(true) + }) + + it('rename moves files', async () => { + const oldPath = path.join(tmpDir, 'before.txt') + const newPath = path.join(tmpDir, 'after.txt') + writeFileSync(oldPath, 'moving') + + await mux.request('fs.rename', { oldPath, newPath }) + + await expect(stat(oldPath)).rejects.toThrow() + const content = await readFile(newPath, 'utf-8') + expect(content).toBe('moving') + }) + + it('copy duplicates files', async () => { + const src = path.join(tmpDir, 'src.txt') + const dst = path.join(tmpDir, 'dst.txt') + writeFileSync(src, 'original') + + await mux.request('fs.copy', { source: src, destination: dst }) + + const content = await readFile(dst, 'utf-8') + expect(content).toBe('original') + }) + + it('readFile returns error for non-existent file', async () => { + await expect( + mux.request('fs.readFile', { filePath: path.join(tmpDir, 'nope.txt') }) + ).rejects.toThrow() + }) + + it('errors propagate correctly through the protocol', async () => { + await expect( + mux.request('fs.stat', { filePath: '/nonexistent/path/that/does/not/exist' }) + ).rejects.toThrow() + }) + }) + + // ─── Git ──────────────────────────────────────────────────────── + + describe('Git operations', () => { + beforeEach(() => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'initial') + gitCommit(tmpDir, 'initial commit') + }) + + it('git.status returns clean status for committed repo', async () => { + const result = (await mux.request('git.status', { + worktreePath: tmpDir + })) as { entries: unknown[]; conflictOperation: string } + + expect(result.entries).toEqual([]) + expect(result.conflictOperation).toBe('unknown') + }) + + it('git.status detects modifications', async () => { + writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') + + const result = (await mux.request('git.status', { + worktreePath: tmpDir + })) as { entries: { path: string; status: string; area: string }[] } + + const entry = result.entries.find((e) => e.path === 'file.txt') + expect(entry).toBeDefined() + expect(entry!.status).toBe('modified') + expect(entry!.area).toBe('unstaged') + }) + + it('git.status detects untracked files', async () => { + writeFileSync(path.join(tmpDir, 'new.txt'), 'new') + + const result = (await mux.request('git.status', { + worktreePath: tmpDir + })) as { entries: { path: string; status: string; area: string }[] } + + const entry = result.entries.find((e) => e.path === 'new.txt') + expect(entry).toBeDefined() + expect(entry!.status).toBe('untracked') + }) + + it('git.stage + git.status shows staged entry', async () => { + writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') + + await mux.request('git.stage', { worktreePath: tmpDir, filePath: 'file.txt' }) + + const result = (await mux.request('git.status', { + worktreePath: tmpDir + })) as { entries: { path: string; area: string }[] } + + const staged = result.entries.find((e) => e.area === 'staged') + expect(staged).toBeDefined() + }) + + it('git.unstage reverses staging', async () => { + writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') + await mux.request('git.stage', { worktreePath: tmpDir, filePath: 'file.txt' }) + await mux.request('git.unstage', { worktreePath: tmpDir, filePath: 'file.txt' }) + + const result = (await mux.request('git.status', { + worktreePath: tmpDir + })) as { entries: { area: string }[] } + + const staged = result.entries.filter((e) => e.area === 'staged') + expect(staged.length).toBe(0) + }) + + it('git.diff returns original and modified content', async () => { + writeFileSync(path.join(tmpDir, 'file.txt'), 'updated content') + + const result = (await mux.request('git.diff', { + worktreePath: tmpDir, + filePath: 'file.txt', + staged: false + })) as { kind: string; originalContent: string; modifiedContent: string } + + expect(result.kind).toBe('text') + expect(result.originalContent).toBe('initial') + expect(result.modifiedContent).toBe('updated content') + }) + + it('git.diff returns staged diff', async () => { + writeFileSync(path.join(tmpDir, 'file.txt'), 'staged version') + execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) + + const result = (await mux.request('git.diff', { + worktreePath: tmpDir, + filePath: 'file.txt', + staged: true + })) as { originalContent: string; modifiedContent: string } + + expect(result.originalContent).toBe('initial') + expect(result.modifiedContent).toBe('staged version') + }) + + it('git.discard restores tracked file to HEAD', async () => { + writeFileSync(path.join(tmpDir, 'file.txt'), 'dirty') + + await mux.request('git.discard', { worktreePath: tmpDir, filePath: 'file.txt' }) + + const content = await readFile(path.join(tmpDir, 'file.txt'), 'utf-8') + expect(content).toBe('initial') + }) + + it('git.discard removes untracked file', async () => { + writeFileSync(path.join(tmpDir, 'temp.txt'), 'throwaway') + + await mux.request('git.discard', { worktreePath: tmpDir, filePath: 'temp.txt' }) + + await expect(stat(path.join(tmpDir, 'temp.txt'))).rejects.toThrow() + }) + + it('git.conflictOperation returns unknown for normal repo', async () => { + const result = await mux.request('git.conflictOperation', { + worktreePath: tmpDir + }) + expect(result).toBe('unknown') + }) + + it('git.listWorktrees returns the main worktree', async () => { + const result = (await mux.request('git.listWorktrees', { + repoPath: tmpDir + })) as { path: string; isMainWorktree: boolean }[] + + expect(result.length).toBeGreaterThanOrEqual(1) + expect(result[0].isMainWorktree).toBe(true) + }) + + it('git.branchCompare works across branches', async () => { + // Get current branch name (might be "main" or "master" depending on config) + const defaultBranch = execFileSync('git', ['branch', '--show-current'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + + execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature work') + gitCommit(tmpDir, 'feature commit') + + const result = (await mux.request('git.branchCompare', { + worktreePath: tmpDir, + baseRef: defaultBranch + })) as { summary: { status: string; commitsAhead: number }; entries: unknown[] } + + expect(result.summary.status).toBe('ready') + expect(result.summary.commitsAhead).toBe(1) + expect(result.entries.length).toBe(1) + }) + + it('git.bulkStage stages multiple files at once', async () => { + writeFileSync(path.join(tmpDir, 'a.txt'), 'a') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b') + + await mux.request('git.bulkStage', { + worktreePath: tmpDir, + filePaths: ['a.txt', 'b.txt'] + }) + + const result = (await mux.request('git.status', { + worktreePath: tmpDir + })) as { entries: { path: string; area: string }[] } + + const staged = result.entries.filter((e) => e.area === 'staged') + expect(staged.length).toBe(2) + }) + }) + + // ─── Error propagation ────────────────────────────────────────── + + describe('Error propagation', () => { + it('method-not-found error for unknown methods', async () => { + await expect(mux.request('nonexistent.method', {})).rejects.toThrow('Method not found') + }) + + it('handler errors propagate as JSON-RPC errors', async () => { + await expect( + mux.request('fs.readFile', { filePath: '/does/not/exist/at/all' }) + ).rejects.toThrow() + }) + }) + + // ─── Notifications ────────────────────────────────────────────── + + describe('Notifications', () => { + it('relay notifications reach the client mux', async () => { + const received: { method: string; params: Record }[] = [] + mux.onNotification((method, params) => { + received.push({ method, params }) + }) + + // Trigger a fs operation that causes the relay to send notifications + // (e.g., write a file — no notification expected for this, so we + // test notification plumbing directly via the relay dispatcher) + dispatcher.notify('custom.event', { key: 'value' }) + + // Wait for the async delivery through setImmediate + await new Promise((r) => setTimeout(r, 50)) + + expect(received.length).toBe(1) + expect(received[0].method).toBe('custom.event') + expect(received[0].params).toEqual({ key: 'value' }) + }) + }) +}) diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts new file mode 100644 index 00000000..dfe9bda7 --- /dev/null +++ b/src/relay/protocol.ts @@ -0,0 +1,137 @@ +// Self-contained relay protocol — mirrors src/main/ssh/relay-protocol.ts +// but has no Electron dependencies. Deployed standalone to remote hosts. + +export const RELAY_VERSION = '0.1.0' +export const RELAY_SENTINEL = `ORCA-RELAY v${RELAY_VERSION} READY\n` + +export const HEADER_LENGTH = 13 +export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024 + +export const MessageType = { + Regular: 1, + KeepAlive: 9 +} as const + +export const KEEPALIVE_SEND_MS = 5_000 +export const TIMEOUT_MS = 20_000 + +export type JsonRpcRequest = { + jsonrpc: '2.0' + id: number + method: string + params?: Record +} + +export type JsonRpcResponse = { + jsonrpc: '2.0' + id: number + result?: unknown + error?: { code: number; message: string; data?: unknown } +} + +export type JsonRpcNotification = { + jsonrpc: '2.0' + method: string + params?: Record +} + +export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification + +export type DecodedFrame = { + type: number + id: number + ack: number + payload: Buffer +} + +export function encodeFrame( + type: number, + id: number, + ack: number, + payload: Buffer | Uint8Array +): Buffer { + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = type + header.writeUInt32BE(id, 1) + header.writeUInt32BE(ack, 5) + header.writeUInt32BE(payload.length, 9) + return Buffer.concat([header, payload]) +} + +export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer { + const payload = Buffer.from(JSON.stringify(msg), 'utf-8') + if (payload.length > MAX_MESSAGE_SIZE) { + throw new Error(`Message too large: ${payload.length} bytes`) + } + return encodeFrame(MessageType.Regular, id, ack, payload) +} + +export function encodeKeepAliveFrame(id: number, ack: number): Buffer { + return encodeFrame(MessageType.KeepAlive, id, ack, Buffer.alloc(0)) +} + +export class FrameDecoder { + private buffer = Buffer.alloc(0) + private onFrame: (frame: DecodedFrame) => void + private onError: ((err: Error) => void) | null + + constructor(onFrame: (frame: DecodedFrame) => void, onError?: (err: Error) => void) { + this.onFrame = onFrame + this.onError = onError ?? null + } + + feed(chunk: Buffer | Uint8Array): void { + this.buffer = Buffer.concat([this.buffer, chunk]) + + while (this.buffer.length >= HEADER_LENGTH) { + const length = this.buffer.readUInt32BE(9) + const totalLength = HEADER_LENGTH + length + + if (length > MAX_MESSAGE_SIZE) { + // Why: Throwing here would leave the buffer in a partially consumed + // state — subsequent feed() calls would try to parse the leftover + // payload bytes as a new header, corrupting every future frame. + // Instead we skip the entire oversized frame so the decoder stays + // synchronized with the stream. + if (this.buffer.length < totalLength) { + // Haven't received the full oversized payload yet; wait for more data. + break + } + this.buffer = this.buffer.subarray(totalLength) + const err = new Error(`Frame payload too large: ${length} bytes — discarded`) + if (this.onError) { + this.onError(err) + } else { + process.stderr.write(`[relay] ${err.message}\n`) + } + continue + } + + if (this.buffer.length < totalLength) { + break + } + + const frame: DecodedFrame = { + type: this.buffer[0], + id: this.buffer.readUInt32BE(1), + ack: this.buffer.readUInt32BE(5), + payload: this.buffer.subarray(HEADER_LENGTH, totalLength) + } + this.buffer = this.buffer.subarray(totalLength) + this.onFrame(frame) + } + } + + reset(): void { + this.buffer = Buffer.alloc(0) + } +} + +export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { + const text = payload.toString('utf-8') + const msg = JSON.parse(text) as JsonRpcMessage + if (msg.jsonrpc !== '2.0') { + throw new Error(`Invalid JSON-RPC version: ${(msg as Record).jsonrpc}`) + } + return msg +} diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts new file mode 100644 index 00000000..9f64ed81 --- /dev/null +++ b/src/relay/pty-handler.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' + +const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockPtyInstance: { + pid: 12345, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ + spawn: mockPtySpawn +})) + +import { PtyHandler } from './pty-handler' +import type { RelayDispatcher } from './dispatcher' + +function createMockDispatcher() { + const requestHandlers = new Map) => Promise>() + const notificationHandlers = new Map) => void>() + const notifications: { method: string; params?: Record }[] = [] + + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ), + onNotification: vi.fn((method: string, handler: (params: Record) => void) => { + notificationHandlers.set(method, handler) + }), + notify: vi.fn((method: string, params?: Record) => { + notifications.push({ method, params }) + }), + // Helpers for tests + _requestHandlers: requestHandlers, + _notificationHandlers: notificationHandlers, + _notifications: notifications, + async callRequest(method: string, params: Record = {}) { + const handler = requestHandlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + return handler(params) + }, + callNotification(method: string, params: Record = {}) { + const handler = notificationHandlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + handler(params) + } + } + + return dispatcher +} + +describe('PtyHandler', () => { + let dispatcher: ReturnType + let handler: PtyHandler + + beforeEach(() => { + vi.useFakeTimers() + mockPtySpawn.mockReset() + mockPtyInstance.onData.mockReset() + mockPtyInstance.onExit.mockReset() + mockPtyInstance.write.mockReset() + mockPtyInstance.resize.mockReset() + mockPtyInstance.kill.mockReset() + mockPtyInstance.clear.mockReset() + + mockPtySpawn.mockReturnValue({ ...mockPtyInstance }) + + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + }) + + afterEach(() => { + handler.dispose() + vi.useRealTimers() + }) + + it('registers all expected handlers', () => { + const methods = Array.from(dispatcher._requestHandlers.keys()) + expect(methods).toContain('pty.spawn') + expect(methods).toContain('pty.attach') + expect(methods).toContain('pty.shutdown') + expect(methods).toContain('pty.sendSignal') + expect(methods).toContain('pty.getCwd') + expect(methods).toContain('pty.getInitialCwd') + expect(methods).toContain('pty.clearBuffer') + expect(methods).toContain('pty.hasChildProcesses') + expect(methods).toContain('pty.getForegroundProcess') + expect(methods).toContain('pty.listProcesses') + expect(methods).toContain('pty.getDefaultShell') + + const notifMethods = Array.from(dispatcher._notificationHandlers.keys()) + expect(notifMethods).toContain('pty.data') + expect(notifMethods).toContain('pty.resize') + expect(notifMethods).toContain('pty.ackData') + }) + + it('spawns a PTY and returns an id', async () => { + const result = await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + expect(result).toEqual({ id: 'pty-1' }) + expect(mockPtySpawn).toHaveBeenCalled() + expect(handler.activePtyCount).toBe(1) + }) + + it('increments PTY ids on each spawn', async () => { + const r1 = await dispatcher.callRequest('pty.spawn', {}) + const r2 = await dispatcher.callRequest('pty.spawn', {}) + expect((r1 as { id: string }).id).toBe('pty-1') + expect((r2 as { id: string }).id).toBe('pty-2') + }) + + it('forwards data from PTY to dispatcher notifications', async () => { + let dataCallback: ((data: string) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + }) + + await dispatcher.callRequest('pty.spawn', {}) + expect(dataCallback).toBeDefined() + + dataCallback!('hello world') + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello world' }) + }) + + it('notifies on PTY exit and removes from map', async () => { + let exitCallback: ((info: { exitCode: number }) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCallback = cb + }) + }) + + await dispatcher.callRequest('pty.spawn', {}) + expect(handler.activePtyCount).toBe(1) + + exitCallback!({ exitCode: 0 }) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: 0 }) + expect(handler.activePtyCount).toBe(0) + }) + + it('writes data to PTY via pty.data notification', async () => { + const mockWrite = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + write: mockWrite, + onData: vi.fn(), + onExit: vi.fn() + }) + + await dispatcher.callRequest('pty.spawn', {}) + dispatcher.callNotification('pty.data', { id: 'pty-1', data: 'ls\n' }) + expect(mockWrite).toHaveBeenCalledWith('ls\n') + }) + + it('resizes PTY via pty.resize notification', async () => { + const mockResize = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + resize: mockResize, + onData: vi.fn(), + onExit: vi.fn() + }) + + await dispatcher.callRequest('pty.spawn', {}) + dispatcher.callNotification('pty.resize', { id: 'pty-1', cols: 120, rows: 40 }) + expect(mockResize).toHaveBeenCalledWith(120, 40) + }) + + it('kills PTY on shutdown with SIGTERM by default', async () => { + const mockKill = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn() + }) + + await dispatcher.callRequest('pty.spawn', {}) + await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false }) + expect(mockKill).toHaveBeenCalledWith('SIGTERM') + }) + + it('kills PTY on shutdown with SIGKILL when immediate', async () => { + const mockKill = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn() + }) + + await dispatcher.callRequest('pty.spawn', {}) + await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + expect(mockKill).toHaveBeenCalledWith('SIGKILL') + }) + + it('throws for attach on nonexistent PTY', async () => { + await expect(dispatcher.callRequest('pty.attach', { id: 'pty-999' })).rejects.toThrow( + 'PTY "pty-999" not found' + ) + }) + + it('grace timer fires immediately when no PTYs exist', () => { + const onExpire = vi.fn() + handler.startGraceTimer(onExpire) + expect(onExpire).toHaveBeenCalledTimes(1) + }) + + it('grace timer fires after configured delay when PTYs exist', async () => { + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(), + onExit: vi.fn() + }) + await dispatcher.callRequest('pty.spawn', {}) + + const onExpire = vi.fn() + handler.startGraceTimer(onExpire) + expect(onExpire).not.toHaveBeenCalled() + + vi.advanceTimersByTime(5 * 60 * 1000) + expect(onExpire).toHaveBeenCalledTimes(1) + }) + + it('cancelGraceTimer prevents expiration', async () => { + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(), + onExit: vi.fn() + }) + await dispatcher.callRequest('pty.spawn', {}) + + const onExpire = vi.fn() + handler.startGraceTimer(onExpire) + + vi.advanceTimersByTime(60_000) + handler.cancelGraceTimer() + + vi.advanceTimersByTime(5 * 60 * 1000) + expect(onExpire).not.toHaveBeenCalled() + }) + + it('dispose kills all PTYs', async () => { + const mockKill = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn() + }) + + await dispatcher.callRequest('pty.spawn', {}) + await dispatcher.callRequest('pty.spawn', {}) + expect(handler.activePtyCount).toBe(2) + + handler.dispose() + expect(mockKill).toHaveBeenCalledWith('SIGTERM') + expect(handler.activePtyCount).toBe(0) + }) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts new file mode 100644 index 00000000..01fd1650 --- /dev/null +++ b/src/relay/pty-handler.ts @@ -0,0 +1,355 @@ +import type { IPty } from 'node-pty' +import type * as NodePty from 'node-pty' +import type { RelayDispatcher } from './dispatcher' +import { + resolveDefaultShell, + resolveProcessCwd, + processHasChildren, + getForegroundProcessName, + listShellProfiles +} from './pty-shell-utils' + +// Why: node-pty is a native addon that may not be installed on the remote. +// Dynamic import keeps the require() lazy so loadPty() returns null gracefully +// when the native module is unavailable. The static type import lets vitest +// intercept it in tests. +let ptyModule: typeof NodePty | null = null +async function loadPty(): Promise { + if (ptyModule) { + return ptyModule + } + try { + ptyModule = await import('node-pty') + return ptyModule + } catch { + return null + } +} + +type ManagedPty = { + id: string + pty: IPty + initialCwd: string + buffered: string + /** Timer for SIGKILL fallback after a graceful SIGTERM shutdown. */ + killTimer?: ReturnType +} +const DEFAULT_GRACE_TIME_MS = 5 * 60 * 1000 +export const REPLAY_BUFFER_MAX = 100 * 1024 +const ALLOWED_SIGNALS = new Set([ + 'SIGINT', + 'SIGTERM', + 'SIGHUP', + 'SIGKILL', + 'SIGTSTP', + 'SIGCONT', + 'SIGUSR1', + 'SIGUSR2' +]) + +type SerializedPtyEntry = { id: string; pid: number; cols: number; rows: number; cwd: string } + +export class PtyHandler { + private ptys = new Map() + private nextId = 1 + private dispatcher: RelayDispatcher + private graceTimeMs: number + private graceTimer: ReturnType | null = null + + constructor(dispatcher: RelayDispatcher, graceTimeMs = DEFAULT_GRACE_TIME_MS) { + this.dispatcher = dispatcher + this.graceTimeMs = graceTimeMs + this.registerHandlers() + } + + /** Wire onData/onExit listeners for a managed PTY and store it. */ + private wireAndStore(managed: ManagedPty): void { + this.ptys.set(managed.id, managed) + managed.pty.onData((data: string) => { + managed.buffered += data + if (managed.buffered.length > REPLAY_BUFFER_MAX) { + managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX) + } + this.dispatcher.notify('pty.data', { id: managed.id, data }) + }) + managed.pty.onExit(({ exitCode }: { exitCode: number }) => { + // Why: If the PTY exits normally (or via SIGTERM), we must clear the + // SIGKILL fallback timer to avoid sending SIGKILL to a recycled PID. + if (managed.killTimer) { + clearTimeout(managed.killTimer) + managed.killTimer = undefined + } + this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode }) + this.ptys.delete(managed.id) + }) + } + + private registerHandlers(): void { + this.dispatcher.onRequest('pty.spawn', (p) => this.spawn(p)) + this.dispatcher.onRequest('pty.attach', (p) => this.attach(p)) + this.dispatcher.onRequest('pty.shutdown', (p) => this.shutdown(p)) + this.dispatcher.onRequest('pty.sendSignal', (p) => this.sendSignal(p)) + this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p)) + this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p)) + this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) + this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) + this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) + this.dispatcher.onRequest('pty.listProcesses', () => this.listProcesses()) + this.dispatcher.onRequest('pty.getDefaultShell', async () => resolveDefaultShell()) + this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p)) + this.dispatcher.onRequest('pty.revive', (p) => this.revive(p)) + this.dispatcher.onRequest('pty.getProfiles', async () => listShellProfiles()) + + this.dispatcher.onNotification('pty.data', (p) => this.writeData(p)) + this.dispatcher.onNotification('pty.resize', (p) => this.resize(p)) + this.dispatcher.onNotification('pty.ackData', (_p) => { + /* flow control ack -- not yet enforced */ + }) + } + + private async spawn(params: Record): Promise<{ id: string }> { + if (this.ptys.size >= 50) { + throw new Error('Maximum number of PTY sessions reached (50)') + } + const pty = await loadPty() + if (!pty) { + throw new Error('node-pty is not available on this remote host') + } + + const cols = (params.cols as number) || 80 + const rows = (params.rows as number) || 24 + const cwd = (params.cwd as string) || process.env.HOME || '/' + const env = params.env as Record | undefined + const shell = resolveDefaultShell() + const id = `pty-${this.nextId++}` + + // Why: SSH exec channels give the relay a minimal environment without + // .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH + // includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh). + const term = pty.spawn(shell, ['-l'], { + name: 'xterm-256color', + cols, + rows, + cwd, + env: { ...process.env, ...env } as Record + }) + + this.wireAndStore({ id, pty: term, initialCwd: cwd, buffered: '' }) + return { id } + } + + private async attach(params: Record): Promise { + const id = params.id as string + const managed = this.ptys.get(id) + if (!managed) { + throw new Error(`PTY "${id}" not found`) + } + + // Replay buffered output + if (managed.buffered) { + this.dispatcher.notify('pty.replay', { id, data: managed.buffered }) + } + } + + private writeData(params: Record): void { + const id = params.id as string + const data = params.data as string + if (typeof data !== 'string') { + return + } + const managed = this.ptys.get(id) + if (managed) { + managed.pty.write(data) + } + } + + private resize(params: Record): void { + const id = params.id as string + const cols = Math.max(1, Math.min(500, Math.floor(Number(params.cols) || 80))) + const rows = Math.max(1, Math.min(500, Math.floor(Number(params.rows) || 24))) + const managed = this.ptys.get(id) + if (managed) { + managed.pty.resize(cols, rows) + } + } + + private async shutdown(params: Record): Promise { + const id = params.id as string + const immediate = params.immediate as boolean + const managed = this.ptys.get(id) + if (!managed) { + return + } + + if (immediate) { + managed.pty.kill('SIGKILL') + } else { + managed.pty.kill('SIGTERM') + + // Why: Some processes ignore SIGTERM (e.g. a hung child, a custom signal + // handler). Without a SIGKILL fallback the PTY process would leak and the + // managed entry would never be cleaned up. The 5-second window gives + // well-behaved processes time to flush and exit gracefully. The timer is + // cleared in the onExit handler if the process terminates on its own. + managed.killTimer = setTimeout(() => { + if (this.ptys.has(id)) { + managed.pty.kill('SIGKILL') + } + }, 5000) + } + } + + private async sendSignal(params: Record): Promise { + const id = params.id as string + const signal = params.signal as string + if (!ALLOWED_SIGNALS.has(signal)) { + throw new Error(`Signal not allowed: ${signal}`) + } + const managed = this.ptys.get(id) + if (!managed) { + throw new Error(`PTY "${id}" not found`) + } + managed.pty.kill(signal) + } + + private async getCwd(params: Record): Promise { + const id = params.id as string + const managed = this.ptys.get(id) + if (!managed) { + throw new Error(`PTY "${id}" not found`) + } + return resolveProcessCwd(managed.pty.pid, managed.initialCwd) + } + + private async getInitialCwd(params: Record): Promise { + const id = params.id as string + const managed = this.ptys.get(id) + if (!managed) { + throw new Error(`PTY "${id}" not found`) + } + return managed.initialCwd + } + + private async clearBuffer(params: Record): Promise { + const id = params.id as string + const managed = this.ptys.get(id) + if (managed) { + managed.pty.clear() + } + } + + private async hasChildProcesses(params: Record): Promise { + const id = params.id as string + const managed = this.ptys.get(id) + if (!managed) { + return false + } + return await processHasChildren(managed.pty.pid) + } + + private async getForegroundProcess(params: Record): Promise { + const id = params.id as string + const managed = this.ptys.get(id) + if (!managed) { + return null + } + return await getForegroundProcessName(managed.pty.pid) + } + + private async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + const results: { id: string; cwd: string; title: string }[] = [] + for (const [id, managed] of this.ptys) { + const title = (await getForegroundProcessName(managed.pty.pid)) || 'shell' + results.push({ id, cwd: managed.initialCwd, title }) + } + return results + } + + private async serialize(params: Record): Promise { + const ids = params.ids as string[] + const entries: SerializedPtyEntry[] = [] + for (const id of ids) { + const managed = this.ptys.get(id) + if (!managed) { + continue + } + const { pid, cols, rows } = managed.pty + entries.push({ id, pid, cols, rows, cwd: managed.initialCwd }) + } + return JSON.stringify(entries) + } + + private async revive(params: Record): Promise { + const state = params.state as string + const entries = JSON.parse(state) as SerializedPtyEntry[] + + for (const entry of entries) { + if (this.ptys.has(entry.id)) { + continue + } + // Only re-attach if the original process is still alive + try { + process.kill(entry.pid, 0) + } catch { + continue + } + const ptyMod = await loadPty() + if (!ptyMod) { + continue + } + const term = ptyMod.spawn(resolveDefaultShell(), ['-l'], { + name: 'xterm-256color', + cols: entry.cols, + rows: entry.rows, + cwd: entry.cwd, + env: process.env as Record + }) + this.wireAndStore({ id: entry.id, pty: term, initialCwd: entry.cwd, buffered: '' }) + + // Why: nextId starts at 1 and is only incremented by spawn(). Revived + // PTYs carry their original IDs (e.g. "pty-3"), so without this bump the + // next spawn() would generate an ID that collides with an already-active + // revived PTY. + const match = entry.id.match(/^pty-(\d+)$/) + if (match) { + const revivedNum = parseInt(match[1], 10) + if (revivedNum >= this.nextId) { + this.nextId = revivedNum + 1 + } + } + } + } + + startGraceTimer(onExpire: () => void): void { + this.cancelGraceTimer() + if (this.ptys.size === 0) { + onExpire() + return + } + this.graceTimer = setTimeout(() => { + onExpire() + }, this.graceTimeMs) + } + + cancelGraceTimer(): void { + if (this.graceTimer) { + clearTimeout(this.graceTimer) + this.graceTimer = null + } + } + + dispose(): void { + this.cancelGraceTimer() + for (const [, managed] of this.ptys) { + if (managed.killTimer) { + clearTimeout(managed.killTimer) + } + managed.pty.kill('SIGTERM') + } + this.ptys.clear() + } + + get activePtyCount(): number { + return this.ptys.size + } +} diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts new file mode 100644 index 00000000..1ce8e20b --- /dev/null +++ b/src/relay/pty-shell-utils.ts @@ -0,0 +1,133 @@ +import { execFile as execFileCb } from 'child_process' +import { existsSync, readFileSync } from 'fs' +import { promisify } from 'util' + +const execFile = promisify(execFileCb) + +/** + * Resolve the default shell for PTY spawning. + * Prefers $SHELL, then common fallbacks. + */ +export function resolveDefaultShell(): string { + const envShell = process.env.SHELL + if (envShell && existsSync(envShell)) { + return envShell + } + + for (const candidate of ['/bin/bash', '/bin/zsh', '/bin/sh']) { + if (existsSync(candidate)) { + return candidate + } + } + return '/bin/sh' +} + +/** + * Resolve the current working directory of a process by pid. + * Tries /proc on Linux and lsof on macOS before falling back to `fallbackCwd`. + */ +export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promise { + // Try to read /proc/{pid}/cwd on Linux + const procCwd = `/proc/${pid}/cwd` + if (existsSync(procCwd)) { + try { + const { readlinkSync } = await import('fs') + return readlinkSync(procCwd) + } catch { + // Fall through + } + } + + // Fallback: use lsof on macOS + // Why: `-d cwd` restricts output to the cwd file descriptor only. Without it, + // lsof returns ALL open files (sockets, log files, TTYs) and the first `n`-line + // could be any of them — not the actual working directory. + try { + const { stdout: output } = await execFile('lsof', ['-p', String(pid), '-d', 'cwd', '-Fn'], { + encoding: 'utf-8', + timeout: 3000 + }) + const lines = output.split('\n') + for (const line of lines) { + if (line.startsWith('n') && line.includes('/')) { + const candidate = line.slice(1) + if (existsSync(candidate)) { + return candidate + } + } + } + } catch { + // Fall through + } + + return fallbackCwd +} + +/** + * Check whether a process has child processes (via pgrep). + */ +export async function processHasChildren(pid: number): Promise { + try { + const { stdout } = await execFile('pgrep', ['-P', String(pid)], { + encoding: 'utf-8', + timeout: 3000 + }) + return stdout.trim().length > 0 + } catch { + return false + } +} + +/** + * Get the foreground process name of a given pid (via ps). + */ +export async function getForegroundProcessName(pid: number): Promise { + try { + const { stdout } = await execFile('ps', ['-o', 'comm=', '-p', String(pid)], { + encoding: 'utf-8', + timeout: 3000 + }) + return stdout.trim() || null + } catch { + return null + } +} + +/** + * List available shell profiles from /etc/shells (or known fallbacks). + */ +export function listShellProfiles(): { name: string; path: string }[] { + const profiles: { name: string; path: string }[] = [] + const seen = new Set() + + try { + const content = readFileSync('/etc/shells', 'utf-8') + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + if (!existsSync(trimmed)) { + continue + } + if (seen.has(trimmed)) { + continue + } + seen.add(trimmed) + + const name = trimmed.split('/').pop() || trimmed + profiles.push({ name, path: trimmed }) + } + } catch { + // /etc/shells may not exist on all systems; fall back to known shells + for (const candidate of ['/bin/bash', '/bin/zsh', '/bin/sh']) { + if (existsSync(candidate) && !seen.has(candidate)) { + seen.add(candidate) + const name = candidate.split('/').pop()! + profiles.push({ name, path: candidate }) + } + } + } + + return profiles +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts new file mode 100644 index 00000000..ff223c9e --- /dev/null +++ b/src/relay/relay.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +// Orca Relay — lightweight daemon deployed to remote hosts. +// Communicates over stdin/stdout using the framed JSON-RPC protocol. +// The Electron app (client) deploys this script via SCP and launches +// it via an SSH exec channel. + +import { RELAY_SENTINEL } from './protocol' +import { RelayDispatcher } from './dispatcher' +import { RelayContext } from './context' +import { PtyHandler } from './pty-handler' +import { FsHandler } from './fs-handler' +import { GitHandler } from './git-handler' + +const DEFAULT_GRACE_MS = 5 * 60 * 1000 + +function parseArgs(argv: string[]): { graceTimeMs: number } { + let graceTimeMs = DEFAULT_GRACE_MS + for (let i = 2; i < argv.length; i++) { + if (argv[i] === '--grace-time' && argv[i + 1]) { + const parsed = parseInt(argv[i + 1], 10) + // Why: the CLI flag is in seconds for ergonomics, but internally we track ms. + if (!isNaN(parsed) && parsed > 0) { + graceTimeMs = parsed * 1000 + } + i++ + } + } + return { graceTimeMs } +} + +function main(): void { + const { graceTimeMs } = parseArgs(process.argv) + + // Why: After an uncaught exception Node's internal state may be corrupted + // (e.g. half-written buffers, broken invariants). Logging and continuing + // would risk silent data corruption or zombie PTYs. We log for diagnostics + // and then exit so the client can detect the disconnect and reconnect cleanly. + process.on('uncaughtException', (err) => { + process.stderr.write(`[relay] Uncaught exception: ${err.message}\n`) + process.exit(1) + }) + + const dispatcher = new RelayDispatcher((data) => { + process.stdout.write(data) + }) + + const context = new RelayContext() + + dispatcher.onNotification('session.registerRoot', (params) => { + const rootPath = params.rootPath as string + if (rootPath) { + context.registerRoot(rootPath) + } + }) + + const ptyHandler = new PtyHandler(dispatcher, graceTimeMs) + const fsHandler = new FsHandler(dispatcher, context) + // Why: GitHandler registers its own request handlers on construction, + // so we hold the reference only for potential future disposal. + const _gitHandler = new GitHandler(dispatcher, context) + void _gitHandler + + // Read framed binary data from stdin + process.stdin.on('data', (chunk: Buffer) => { + ptyHandler.cancelGraceTimer() + dispatcher.feed(chunk) + }) + + process.stdin.on('end', () => { + // Client disconnected — start grace timer to keep PTYs alive + // for possible reconnection + ptyHandler.startGraceTimer(() => { + shutdown() + }) + }) + + process.stdin.on('error', () => { + ptyHandler.startGraceTimer(() => { + shutdown() + }) + }) + + function shutdown(): void { + dispatcher.dispose() + ptyHandler.dispose() + fsHandler.dispose() + process.exit(0) + } + + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) + + // Signal readiness to the client — the client watches for this exact + // string before sending framed data. + process.stdout.write(RELAY_SENTINEL) +} + +main() diff --git a/src/relay/subprocess-test-utils.ts b/src/relay/subprocess-test-utils.ts new file mode 100644 index 00000000..10c9e7c4 --- /dev/null +++ b/src/relay/subprocess-test-utils.ts @@ -0,0 +1,170 @@ +import { spawn, type ChildProcess } from 'child_process' +import { + RELAY_SENTINEL, + FrameDecoder, + encodeJsonRpcFrame, + parseJsonRpcMessage, + MessageType, + type JsonRpcRequest, + type JsonRpcResponse, + type JsonRpcNotification +} from './protocol' + +export type RelayProcess = { + proc: ChildProcess + responses: (JsonRpcResponse | JsonRpcNotification)[] + sentinelReceived: Promise + send: (method: string, params?: Record) => number + sendNotification: (method: string, params?: Record) => void + waitForResponse: (id: number, timeoutMs?: number) => Promise + waitForNotification: (method: string, timeoutMs?: number) => Promise + kill: (signal?: NodeJS.Signals) => void + waitForExit: (timeoutMs?: number) => Promise +} + +export function spawnRelay(entryPath: string, args: string[] = []): RelayProcess { + const proc = spawn('node', [entryPath, ...args], { + stdio: ['pipe', 'pipe', 'pipe'] + }) + + const responses: (JsonRpcResponse | JsonRpcNotification)[] = [] + let nextSeq = 1 + let sentinelResolved = false + let stdoutBuffer = Buffer.alloc(0) + let sentinelResolve: () => void + let decoderActive = false + + const sentinelReceived = new Promise((resolve) => { + sentinelResolve = resolve + }) + + const decoder = new FrameDecoder((frame) => { + if (frame.type !== MessageType.Regular) { + return + } + try { + const msg = parseJsonRpcMessage(frame.payload) + responses.push(msg as JsonRpcResponse | JsonRpcNotification) + } catch { + /* skip malformed */ + } + }) + + proc.stdout!.on('data', (chunk: Buffer) => { + if (!sentinelResolved) { + stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) + const sentinelBuf = Buffer.from(RELAY_SENTINEL, 'utf-8') + const idx = stdoutBuffer.indexOf(sentinelBuf) + if (idx !== -1) { + sentinelResolved = true + decoderActive = true + sentinelResolve() + const remainder = stdoutBuffer.subarray(idx + sentinelBuf.length) + if (remainder.length > 0) { + decoder.feed(remainder) + } + } + } else if (decoderActive) { + decoder.feed(chunk) + } + }) + + proc.stderr!.on('data', () => { + /* drain */ + }) + + const send = (method: string, params?: Record): number => { + const id = nextSeq++ + const req: JsonRpcRequest = { + jsonrpc: '2.0', + id, + method, + ...(params !== undefined ? { params } : {}) + } + proc.stdin!.write(encodeJsonRpcFrame(req, id, 0)) + return id + } + + const sendNotification = (method: string, params?: Record): void => { + const seq = nextSeq++ + const notif: JsonRpcNotification = { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + } + proc.stdin!.write(encodeJsonRpcFrame(notif, seq, 0)) + } + + const waitForResponse = (id: number, timeoutMs = 5000): Promise => { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs + const check = () => { + const found = responses.find((r) => 'id' in r && r.id === id) as JsonRpcResponse | undefined + if (found) { + resolve(found) + return + } + if (Date.now() > deadline) { + reject(new Error(`Timed out waiting for response id=${id}`)) + return + } + setTimeout(check, 10) + } + check() + }) + } + + const waitForNotification = (method: string, timeoutMs = 5000): Promise => { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs + const seen = responses.length + const check = () => { + for (let i = seen; i < responses.length; i++) { + const r = responses[i] + if ('method' in r && r.method === method) { + resolve(r as JsonRpcNotification) + return + } + } + if (Date.now() > deadline) { + reject(new Error(`Timed out waiting for notification "${method}"`)) + return + } + setTimeout(check, 10) + } + check() + }) + } + + const kill = (signal: NodeJS.Signals = 'SIGTERM') => { + proc.kill(signal) + } + + const waitForExit = (timeoutMs = 5000): Promise => { + return new Promise((resolve, reject) => { + if (proc.exitCode !== null) { + resolve(proc.exitCode) + return + } + const timer = setTimeout(() => { + reject(new Error('Timed out waiting for process exit')) + }, timeoutMs) + proc.once('exit', (code) => { + clearTimeout(timer) + resolve(code) + }) + }) + } + + return { + proc, + responses, + sentinelReceived, + send, + sendNotification, + waitForResponse, + waitForNotification, + kill, + waitForExit + } +} diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts new file mode 100644 index 00000000..d17ecfb4 --- /dev/null +++ b/src/relay/subprocess.test.ts @@ -0,0 +1,202 @@ +import { afterAll, beforeAll, describe, expect, it, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync } from 'fs' +import { rm } from 'fs/promises' +import * as path from 'path' +import { tmpdir } from 'os' +import { execFileSync } from 'child_process' +import { build } from 'esbuild' +import { spawnRelay, type RelayProcess } from './subprocess-test-utils' + +const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts') +let bundleDir: string +let relayEntry: string + +beforeAll(async () => { + bundleDir = mkdtempSync(path.join(tmpdir(), 'relay-bundle-')) + relayEntry = path.join(bundleDir, 'relay.js') + await build({ + entryPoints: [RELAY_TS_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: relayEntry, + external: ['node-pty', '@parcel/watcher'], + sourcemap: false + }) +}, 30_000) + +afterAll(async () => { + if (bundleDir) { + await rm(bundleDir, { recursive: true, force: true }).catch(() => {}) + } +}) + +function spawn(args: string[] = []): RelayProcess { + return spawnRelay(relayEntry, args) +} + +describe('Subprocess: Relay entry point', () => { + let relay: RelayProcess | null = null + let tmpDir: string + + afterEach(async () => { + if (relay && relay.proc.exitCode === null) { + relay.proc.kill('SIGKILL') + await relay.waitForExit().catch(() => {}) + } + relay = null + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + } + }) + + it('prints sentinel on startup', async () => { + relay = spawn() + await relay.sentinelReceived + }, 10_000) + + it('responds to fs.stat over stdin/stdout', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) + writeFileSync(path.join(tmpDir, 'test.txt'), 'hello') + + relay = spawn() + await relay.sentinelReceived + relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) + + const id = relay.send('fs.stat', { filePath: path.join(tmpDir, 'test.txt') }) + const resp = await relay.waitForResponse(id) + + expect(resp.result).toBeDefined() + const result = resp.result as { size: number; type: string } + expect(result.type).toBe('file') + expect(result.size).toBe(5) + }, 10_000) + + it('responds to fs.readDir', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) + writeFileSync(path.join(tmpDir, 'a.txt'), 'a') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b') + + relay = spawn() + await relay.sentinelReceived + relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) + + const id = relay.send('fs.readDir', { dirPath: tmpDir }) + const resp = await relay.waitForResponse(id) + + const entries = resp.result as { name: string }[] + const names = entries.map((e) => e.name).sort() + expect(names).toEqual(['a.txt', 'b.txt']) + }, 10_000) + + it('responds to fs.readFile and fs.writeFile', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) + + relay = spawn() + await relay.sentinelReceived + relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) + + const filePath = path.join(tmpDir, 'output.txt') + const wId = relay.send('fs.writeFile', { filePath, content: 'via subprocess' }) + const wResp = await relay.waitForResponse(wId) + expect(wResp.error).toBeUndefined() + + const rId = relay.send('fs.readFile', { filePath }) + const rResp = await relay.waitForResponse(rId) + const result = rResp.result as { content: string; isBinary: boolean } + expect(result.content).toBe('via subprocess') + expect(result.isBinary).toBe(false) + }, 10_000) + + it('responds to git.status on a real repo', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) + execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'file.txt'), 'content') + execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'file.txt'), 'dirty') + + relay = spawn() + await relay.sentinelReceived + relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) + + const id = relay.send('git.status', { worktreePath: tmpDir }) + const resp = await relay.waitForResponse(id) + + const result = resp.result as { entries: { path: string; status: string }[] } + expect(result.entries.length).toBeGreaterThan(0) + expect(result.entries[0].path).toBe('file.txt') + expect(result.entries[0].status).toBe('modified') + }, 10_000) + + it('returns JSON-RPC error for unknown method', async () => { + relay = spawn() + await relay.sentinelReceived + + const id = relay.send('does.not.exist', {}) + const resp = await relay.waitForResponse(id) + + expect(resp.error).toBeDefined() + expect(resp.error!.code).toBe(-32601) + expect(resp.error!.message).toContain('Method not found') + }, 10_000) + + it('returns error for failing handler', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) + relay = spawn() + await relay.sentinelReceived + relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) + + const id = relay.send('fs.readFile', { filePath: path.join(tmpDir, 'nonexistent.txt') }) + const resp = await relay.waitForResponse(id) + + expect(resp.error).toBeDefined() + }, 10_000) + + it('handles multiple concurrent requests', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) + writeFileSync(path.join(tmpDir, 'one.txt'), '1') + writeFileSync(path.join(tmpDir, 'two.txt'), '22') + writeFileSync(path.join(tmpDir, 'three.txt'), '333') + + relay = spawn() + await relay.sentinelReceived + relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) + + const id1 = relay.send('fs.stat', { filePath: path.join(tmpDir, 'one.txt') }) + const id2 = relay.send('fs.stat', { filePath: path.join(tmpDir, 'two.txt') }) + const id3 = relay.send('fs.stat', { filePath: path.join(tmpDir, 'three.txt') }) + + const [r1, r2, r3] = await Promise.all([ + relay.waitForResponse(id1), + relay.waitForResponse(id2), + relay.waitForResponse(id3) + ]) + + expect((r1.result as { size: number }).size).toBe(1) + expect((r2.result as { size: number }).size).toBe(2) + expect((r3.result as { size: number }).size).toBe(3) + }, 10_000) + + it('shuts down cleanly on SIGTERM', async () => { + relay = spawn() + await relay.sentinelReceived + + relay.kill('SIGTERM') + await relay.waitForExit() + expect(relay.proc.exitCode !== null || relay.proc.signalCode !== null).toBe(true) + }, 10_000) + + it('exits immediately on stdin close when no PTYs exist', async () => { + relay = spawn(['--grace-time', '100']) + await relay.sentinelReceived + + relay.proc.stdin!.end() + + await relay.waitForExit(3000) + expect(relay.proc.exitCode).toBe(0) + }, 10_000) +}) diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 19c3ec69..4a1e2b50 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -8,6 +8,7 @@ import type { editor as monacoEditor } from 'monaco-editor' import { useAppStore } from '@/store' import { joinPath } from '@/lib/path' import { setWithLRU } from '@/lib/scroll-cache' +import { getConnectionId } from '@/lib/connection-context' import '@/lib/monaco-setup' import { Button } from '@/components/ui/button' import type { OpenFile } from '@/store/slices/editor' @@ -191,6 +192,7 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React. let result: GitDiffResult try { + const connectionId = getConnectionId(file.worktreeId) ?? undefined result = isBranchMode && branchCompare ? ((await window.api.git.branchDiff({ @@ -202,12 +204,14 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React. mergeBase: branchCompare.mergeBase! }, filePath: entry.path, - oldPath: entry.oldPath + oldPath: entry.oldPath, + connectionId })) as GitDiffResult) : ((await window.api.git.diff({ worktreePath: file.filePath, filePath: entry.path, - staged: 'area' in entry && entry.area === 'staged' + staged: 'area' in entry && entry.area === 'staged', + connectionId })) as GitDiffResult) } catch { result = { @@ -268,7 +272,8 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React. const content = modifiedEditor.getValue() const absolutePath = joinPath(file.filePath, section.path) try { - await window.api.fs.writeFile({ filePath: absolutePath, content }) + const connectionId = getConnectionId(file.worktreeId) ?? undefined + await window.api.fs.writeFile({ filePath: absolutePath, content, connectionId }) setSections((prev) => prev.map((s, i) => (i === index ? { ...s, modifiedContent: content, dirty: false } : s)) ) @@ -276,7 +281,7 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React. console.error('Save failed:', err) } }, - [file.filePath, sections] + [file.filePath, file.worktreeId, sections] ) const handleSectionSaveRef = useRef(handleSectionSave) diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 519dc624..1ef9991d 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useEffect, useRef, useState, Suspense } from 'react import * as monaco from 'monaco-editor' import { Columns2, Copy, ExternalLink, FileText, Rows2 } from 'lucide-react' import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' import { getEditorHeaderCopyState, getEditorHeaderOpenFileState } from './editor-header' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -160,7 +161,7 @@ export default function EditorPanel({ if (fileContents[activeFile.id]) { return } - void loadFileContent(activeFile.filePath, activeFile.id) + void loadFileContent(activeFile.filePath, activeFile.id, activeFile.worktreeId) } else if ( activeFile.mode === 'diff' && activeFile.diffSource !== undefined && @@ -182,17 +183,21 @@ export default function EditorPanel({ return () => window.clearTimeout(timeout) }, [copiedPathToast]) - const loadFileContent = useCallback(async (filePath: string, id: string): Promise => { - try { - const result = (await window.api.fs.readFile({ filePath })) as FileContent - setFileContents((prev) => ({ ...prev, [id]: result })) - } catch (err) { - setFileContents((prev) => ({ - ...prev, - [id]: { content: `Error loading file: ${err}`, isBinary: false } - })) - } - }, []) + const loadFileContent = useCallback( + async (filePath: string, id: string, worktreeId?: string): Promise => { + try { + const connectionId = getConnectionId(worktreeId ?? null) ?? undefined + const result = (await window.api.fs.readFile({ filePath, connectionId })) as FileContent + setFileContents((prev) => ({ ...prev, [id]: result })) + } catch (err) { + setFileContents((prev) => ({ + ...prev, + [id]: { content: `Error loading file: ${err}`, isBinary: false } + })) + } + }, + [] + ) const loadDiffContent = useCallback(async (file: OpenFile | null): Promise => { if (!file) { @@ -208,6 +213,7 @@ export default function EditorPanel({ file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase ? file.branchCompare : null + const connectionId = getConnectionId(file.worktreeId) ?? undefined const result = file.diffSource === 'branch' && branchCompare ? ((await window.api.git.branchDiff({ @@ -219,12 +225,14 @@ export default function EditorPanel({ mergeBase: branchCompare.mergeBase! }, filePath: file.relativePath, - oldPath: file.branchOldPath + oldPath: file.branchOldPath, + connectionId })) as DiffContent) : ((await window.api.git.diff({ worktreePath, filePath: file.relativePath, - staged: file.diffSource === 'staged' + staged: file.diffSource === 'staged', + connectionId })) as DiffContent) setDiffContents((prev) => ({ ...prev, [file.id]: result })) } catch (err) { @@ -320,7 +328,7 @@ export default function EditorPanel({ for (const file of matchingFiles) { if (file.mode === 'edit') { - void loadFileContent(file.filePath, file.id) + void loadFileContent(file.filePath, file.id, file.worktreeId) } else if ( file.mode === 'diff' && file.diffSource !== 'combined-uncommitted' && diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 8d386744..9cb829f9 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -9,6 +9,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' import { scrollTopCache, cursorPositionCache, setWithLRU } from '@/lib/scroll-cache' import '@/lib/monaco-setup' import { computeEditorFontSize } from '@/lib/editor-font-zoom' @@ -373,10 +374,15 @@ export default function MonacoEditor({ onSelect={async () => { // Derive worktree root from the absolute and relative paths const worktreePath = filePath.slice(0, -(relativePath.length + 1)) + const activeFile = useAppStore + .getState() + .openFiles.find((f) => f.filePath === filePath) + const connectionId = getConnectionId(activeFile?.worktreeId ?? null) ?? undefined const url = await window.api.git.remoteFileUrl({ worktreePath, relativePath, - line: gutterMenuLine + line: gutterMenuLine, + connectionId }) if (url) { window.api.ui.writeClipboardText(url) diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 950ba0aa..baddf3a1 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -2,6 +2,7 @@ import type { StoreApi } from 'zustand' import { useAppStore } from '@/store' import type { AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' +import { getConnectionId } from '@/lib/connection-context' import { canAutoSaveOpenFile, getOpenFilesForExternalFileChange, @@ -72,7 +73,12 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { } const contentToSave = state.editorDrafts[file.id] ?? fallbackContent - await window.api.fs.writeFile({ filePath: liveFile.filePath, content: contentToSave }) + const connectionId = getConnectionId(liveFile.worktreeId) ?? undefined + await window.api.fs.writeFile({ + filePath: liveFile.filePath, + content: contentToSave, + connectionId + }) if ((saveGeneration.get(file.id) ?? 0) !== queuedGeneration) { return diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index ea637f76..eba651c8 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -81,7 +81,11 @@ function isExternalUrl(src: string): boolean { * returns the URL directly. Re-validates on window re-focus so deleted or * replaced images are picked up. */ -export function useLocalImageSrc(rawSrc: string | undefined, filePath: string): string | undefined { +export function useLocalImageSrc( + rawSrc: string | undefined, + filePath: string, + connectionId?: string | null +): string | undefined { const [generation, setGeneration] = useState(cacheGeneration) useEffect(() => { @@ -126,7 +130,7 @@ export function useLocalImageSrc(rawSrc: string | undefined, filePath: string): let cancelled = false window.api.fs - .readFile({ filePath: absolutePath }) + .readFile({ filePath: absolutePath, connectionId: connectionId ?? undefined }) .then((result) => { if (cancelled) { return @@ -151,7 +155,7 @@ export function useLocalImageSrc(rawSrc: string | undefined, filePath: string): return () => { cancelled = true } - }, [rawSrc, filePath, generation]) + }, [rawSrc, filePath, generation, connectionId]) return displaySrc } @@ -161,7 +165,11 @@ export function useLocalImageSrc(rawSrc: string | undefined, filePath: string): * outside React (e.g. ProseMirror nodeViews). Resolves from cache when * available. */ -export async function loadLocalImageSrc(rawSrc: string, filePath: string): Promise { +export async function loadLocalImageSrc( + rawSrc: string, + filePath: string, + connectionId?: string | null +): Promise { if ( rawSrc.startsWith('http://') || rawSrc.startsWith('https://') || @@ -182,7 +190,10 @@ export async function loadLocalImageSrc(rawSrc: string, filePath: string): Promi } try { - const result = await window.api.fs.readFile({ filePath: absolutePath }) + const result = await window.api.fs.readFile({ + filePath: absolutePath, + connectionId: connectionId ?? undefined + }) if (result.isBinary && result.content) { const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') cacheBlobUrl(absolutePath, url) diff --git a/src/renderer/src/components/repo/RepoCombobox.tsx b/src/renderer/src/components/repo/RepoCombobox.tsx index 1b03c976..39b8a26d 100644 --- a/src/renderer/src/components/repo/RepoCombobox.tsx +++ b/src/renderer/src/components/repo/RepoCombobox.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react' -import { Check, ChevronsUpDown } from 'lucide-react' +import { Check, ChevronsUpDown, Globe } from 'lucide-react' import { Button } from '@/components/ui/button' import { Command, @@ -67,11 +67,19 @@ export default function RepoCombobox({ data-repo-combobox-root="true" > {selectedRepo ? ( - + + + {selectedRepo.connectionId && ( + + + SSH + + )} + ) : ( {placeholder} )} @@ -106,11 +114,19 @@ export default function RepoCombobox({ )} />
- + + + {repo.connectionId && ( + + + SSH + + )} +

{repo.path}

diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 7d23b207..e8894c7c 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -55,7 +55,7 @@ export default function FileExplorer(): React.JSX.Element { refreshTree, refreshDir, resetAndLoad - } = useFileExplorerTree(worktreePath, expanded) + } = useFileExplorerTree(worktreePath, expanded, activeWorktreeId) const [selectedPath, setSelectedPath] = useState(null) const [flashingPath, setFlashingPath] = useState(null) diff --git a/src/renderer/src/components/right-sidebar/Search.tsx b/src/renderer/src/components/right-sidebar/Search.tsx index 669fe504..16f5e00a 100644 --- a/src/renderer/src/components/right-sidebar/Search.tsx +++ b/src/renderer/src/components/right-sidebar/Search.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' import type { SearchFileResult, SearchMatch } from '../../../../shared/types' import { buildSearchRows } from './search-rows' import { cancelRevealFrame, openMatchResult } from './search-match-open' @@ -183,9 +184,11 @@ export default function Search(): React.JSX.Element { searchTimerRef.current = null 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, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 09930815..8b96a3b3 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -47,6 +47,7 @@ import { notifyEditorExternalFileChange, requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' +import { getConnectionId } from '@/lib/connection-context' import { PullRequestIcon } from './checks-helpers' import type { GitBranchChangeEntry, @@ -332,12 +333,13 @@ export default function SourceControl(): React.JSX.Element { } setIsExecutingBulk(true) try { - await window.api.git.bulkStage({ worktreePath, filePaths: bulkStagePaths }) + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + await window.api.git.bulkStage({ worktreePath, filePaths: bulkStagePaths, connectionId }) clearSelection() } finally { setIsExecutingBulk(false) } - }, [worktreePath, bulkStagePaths, clearSelection]) + }, [worktreePath, bulkStagePaths, clearSelection, activeWorktreeId]) const handleBulkUnstage = useCallback(async () => { if (!worktreePath || bulkUnstagePaths.length === 0) { @@ -345,12 +347,13 @@ export default function SourceControl(): React.JSX.Element { } setIsExecutingBulk(true) try { - await window.api.git.bulkUnstage({ worktreePath, filePaths: bulkUnstagePaths }) + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + await window.api.git.bulkUnstage({ worktreePath, filePaths: bulkUnstagePaths, connectionId }) clearSelection() } finally { setIsExecutingBulk(false) } - }, [worktreePath, bulkUnstagePaths, clearSelection]) + }, [worktreePath, bulkUnstagePaths, clearSelection, activeWorktreeId]) const unresolvedConflicts = useMemo( () => entries.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind), @@ -387,9 +390,11 @@ export default function SourceControl(): React.JSX.Element { } try { + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined const result = await window.api.git.branchCompare({ worktreePath, - baseRef: effectiveBaseRef + baseRef: effectiveBaseRef, + connectionId }) setGitBranchCompareResult(activeWorktreeId, requestKey, result) } catch (error) { @@ -472,12 +477,13 @@ export default function SourceControl(): React.JSX.Element { return } try { - await window.api.git.stage({ worktreePath, filePath }) + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + await window.api.git.stage({ worktreePath, filePath, connectionId }) } catch { // git operation failed silently } }, - [worktreePath] + [worktreePath, activeWorktreeId] ) const handleUnstage = useCallback( @@ -486,12 +492,13 @@ export default function SourceControl(): React.JSX.Element { return } try { - await window.api.git.unstage({ worktreePath, filePath }) + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + await window.api.git.unstage({ worktreePath, filePath, connectionId }) } catch { // git operation failed silently } }, - [worktreePath] + [worktreePath, activeWorktreeId] ) const handleDiscard = useCallback( @@ -508,7 +515,8 @@ export default function SourceControl(): React.JSX.Element { worktreePath, relativePath: filePath }) - await window.api.git.discard({ worktreePath, filePath }) + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + await window.api.git.discard({ worktreePath, filePath, connectionId }) notifyEditorExternalFileChange({ worktreeId: activeWorktreeId, worktreePath, diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index a534da95..832a933c 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -3,6 +3,7 @@ import type { Dispatch, SetStateAction } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { dirname } from '@/lib/path' +import { getConnectionId } from '@/lib/connection-context' import { isPathEqualOrDescendant } from './file-explorer-paths' import type { PendingDelete, TreeNode } from './file-explorer-types' import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' @@ -89,7 +90,8 @@ export function useFileDeletion({ // action cannot be undone by a trailing write that recreates the file. await Promise.all(filesToClose.map((file) => requestEditorSaveQuiesce({ fileId: file.id }))) - await window.api.fs.deletePath({ targetPath: node.path }) + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + await window.api.fs.deletePath({ targetPath: node.path, connectionId }) for (const file of filesToClose) { closeFile(file.id) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index c8a59512..4e8f5428 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { basename, dirname, joinPath } from '@/lib/path' import { detectLanguage } from '@/lib/language-detect' +import { getConnectionId } from '@/lib/connection-context' import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' function extractIpcErrorMessage(err: unknown, fallback: string): string { @@ -150,7 +151,8 @@ export function useFileExplorerDragDrop({ await Promise.all(filesToMove.map((file) => requestEditorSaveQuiesce({ fileId: file.id }))) try { - await window.api.fs.rename({ oldPath: sourcePath, newPath }) + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + await window.api.fs.rename({ oldPath: sourcePath, newPath, connectionId }) } catch (err) { toast.error(extractIpcErrorMessage(err, `Failed to move '${fileName}'.`)) return diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts index d7c88f13..9742f92f 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { detectLanguage } from '@/lib/language-detect' import { dirname, joinPath } from '@/lib/path' +import { getConnectionId } from '@/lib/connection-context' import type { InlineInput } from './FileExplorerRow' import type { TreeNode } from './file-explorer-types' @@ -119,12 +120,14 @@ export function useFileExplorerInlineInput({ return } const run = async (): Promise => { + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined if (inlineInput.type === 'rename' && inlineInput.existingPath) { const parentDir = dirname(inlineInput.existingPath) try { await window.api.fs.rename({ oldPath: inlineInput.existingPath, - newPath: joinPath(parentDir, name) + newPath: joinPath(parentDir, name), + connectionId }) } catch (err) { toast.error( @@ -136,8 +139,8 @@ export function useFileExplorerInlineInput({ const fullPath = joinPath(inlineInput.parentPath, name) try { await (inlineInput.type === 'folder' - ? window.api.fs.createDir({ dirPath: fullPath }) - : window.api.fs.createFile({ filePath: fullPath })) + ? window.api.fs.createDir({ dirPath: fullPath, connectionId }) + : window.api.fs.createFile({ filePath: fullPath, connectionId })) await refreshDir(inlineInput.parentPath) if (inlineInput.type === 'file') { openFile({ diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index 3388744f..a09c31b3 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -1,6 +1,7 @@ import type React from 'react' import { useCallback, useMemo, useRef, useState } from 'react' import { joinPath, normalizeRelativePath } from '@/lib/path' +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' @@ -20,7 +21,8 @@ type UseFileExplorerTreeResult = { export function useFileExplorerTree( worktreePath: string | null, - expanded: Set + expanded: Set, + activeWorktreeId?: string | null ): UseFileExplorerTreeResult { const [dirCache, setDirCache] = useState>({}) const [rootError, setRootError] = useState(null) @@ -45,7 +47,8 @@ export function useFileExplorerTree( } })) try { - const entries = await window.api.fs.readDir({ dirPath }) + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const entries = await window.api.fs.readDir({ dirPath, connectionId }) if (depth === -1) { setRootError(null) } @@ -72,7 +75,7 @@ export function useFileExplorerTree( setDirCache((prev) => ({ ...prev, [dirPath]: { children: [], loading: false } })) } }, - [worktreePath] + [activeWorktreeId, worktreePath] ) const refreshTree = useCallback(async () => { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index d924d974..9d1f3743 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -5,6 +5,7 @@ import type { DirCache } from './file-explorer-types' import type { InlineInput } from './FileExplorerRow' import { normalizeAbsolutePath } from './file-explorer-paths' import { dirname } from '@/lib/path' +import { getConnectionId } from '@/lib/connection-context' import { purgeDirCacheSubtree, purgeExpandedDirsSubtree, @@ -88,7 +89,8 @@ export function useFileExplorerWatch({ const currentWorktreePath = worktreePath - void window.api.fs.watchWorktree({ worktreePath }) + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + void window.api.fs.watchWorktree({ worktreePath, connectionId }) function processPayload(payload: FsChangedPayload): void { // Why: during rapid worktree switches, in-flight batched events from @@ -209,10 +211,10 @@ export function useFileExplorerWatch({ return () => { unsubscribeListener() - void window.api.fs.unwatchWorktree({ worktreePath }) + void window.api.fs.unwatchWorktree({ worktreePath, connectionId }) deferredRef.current = [] } - }, [worktreePath, setDirCache, setSelectedPath]) + }, [worktreePath, activeWorktreeId, setDirCache, setSelectedPath]) // ── Flush deferred events when interaction ends ──────────────────── useEffect(() => { diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 107d80c0..fbc1754c 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react' import { useAppStore } from '@/store' import type { GitConflictOperation, GitStatusResult } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getConnectionId } from '@/lib/connection-context' const POLL_INTERVAL_MS = 3000 @@ -70,7 +71,11 @@ export function useGitStatusPolling(): void { return } try { - const status = (await window.api.git.status({ worktreePath })) as GitStatusResult + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const status = (await window.api.git.status({ + worktreePath, + connectionId + })) as GitStatusResult setGitStatus(activeWorktreeId, status) } catch { // ignore @@ -109,7 +114,8 @@ export function useGitStatusPolling(): void { for (const { id, path } of staleConflictWorktrees) { try { const op = (await window.api.git.conflictOperation({ - worktreePath: path + worktreePath: path, + connectionId: getConnectionId(id) ?? undefined })) as GitConflictOperation setConflictOperation(id, op) } catch { diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 5dfc641b..62d04b0e 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -6,6 +6,7 @@ import { GitBranch, Keyboard, Palette, + Server, SlidersHorizontal, SquareTerminal } from 'lucide-react' @@ -21,6 +22,7 @@ import { TerminalPane, TERMINAL_PANE_SEARCH_ENTRIES } from './TerminalPane' import { RepositoryPane, getRepositoryPaneSearchEntries } from './RepositoryPane' import { GitPane, GIT_PANE_SEARCH_ENTRIES } from './GitPane' import { NotificationsPane, NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './NotificationsPane' +import { SshPane, SSH_PANE_SEARCH_ENTRIES } from './SshPane' import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane' import { SettingsSidebar } from './SettingsSidebar' import { SettingsSection } from './SettingsSection' @@ -34,6 +36,7 @@ type SettingsNavTarget = | 'notifications' | 'shortcuts' | 'stats' + | 'ssh' | 'repo' type SettingsNavSection = { @@ -42,6 +45,7 @@ type SettingsNavSection = { description: string icon: typeof SlidersHorizontal searchEntries: SettingsSearchEntry[] + badge?: string } function getSettingsSectionId(pane: SettingsNavTarget, repoId: string | null): string { @@ -259,6 +263,14 @@ function Settings(): React.JSX.Element { icon: BarChart3, searchEntries: STATS_PANE_SEARCH_ENTRIES }, + { + id: 'ssh', + title: 'SSH', + description: 'Remote SSH connections.', + icon: Server, + searchEntries: SSH_PANE_SEARCH_ENTRIES, + badge: 'Beta' + }, ...repos.map((repo) => ({ id: `repo-${repo.id}`, title: repo.displayName, @@ -369,7 +381,7 @@ function Settings(): React.JSX.Element { .filter((section) => section.id.startsWith('repo-')) .map((section) => { const repo = repos.find((entry) => entry.id === section.id.replace('repo-', '')) - return { ...section, badgeColor: repo?.badgeColor } + return { ...section, badgeColor: repo?.badgeColor, isRemote: !!repo?.connectionId } }) return ( @@ -472,6 +484,16 @@ function Settings(): React.JSX.Element { + + + + {repos.map((repo) => { const repoSectionId = `repo-${repo.id}` const repoHooksState = repoHooksMap[repo.id] diff --git a/src/renderer/src/components/settings/SettingsSection.tsx b/src/renderer/src/components/settings/SettingsSection.tsx index 9b94e3e3..8ac7bc07 100644 --- a/src/renderer/src/components/settings/SettingsSection.tsx +++ b/src/renderer/src/components/settings/SettingsSection.tsx @@ -9,6 +9,7 @@ type SettingsSectionProps = { searchEntries: SettingsSearchEntry[] children: React.ReactNode className?: string + badge?: string } export function SettingsSection({ @@ -17,7 +18,8 @@ export function SettingsSection({ description, searchEntries, children, - className + className, + badge }: SettingsSectionProps): React.JSX.Element | null { const query = useAppStore((state) => state.settingsSearchQuery) if (!matchesSettingsSearch(query, searchEntries)) { @@ -37,7 +39,14 @@ export function SettingsSection({ } >
-

{title}

+

+ {title} + {badge ? ( + + {badge} + + ) : null} +

{description}

{children} diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 2fc5e6e1..0d8276d7 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Search, type LucideIcon, type LucideProps } from 'lucide-react' +import { ArrowLeft, Globe, Search, type LucideIcon, type LucideProps } from 'lucide-react' import { Button } from '../ui/button' import { Input } from '../ui/input' @@ -6,10 +6,12 @@ type NavSection = { id: string title: string icon: LucideIcon | ((props: LucideProps) => React.JSX.Element) + badge?: string } type RepoNavSection = NavSection & { badgeColor?: string + isRemote?: boolean } type SettingsSidebarProps = { @@ -78,6 +80,11 @@ export function SettingsSidebar({ > {section.title} + {section.badge ? ( + + {section.badge} + + ) : null} ) })} @@ -108,6 +115,12 @@ export function SettingsSidebar({ style={{ backgroundColor: section.badgeColor ?? '#6b7280' }} /> {section.title} + {section.isRemote && ( + + + SSH + + )} ) })} diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx new file mode 100644 index 00000000..c52dd0ac --- /dev/null +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -0,0 +1,306 @@ +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import { Plus, Upload } from 'lucide-react' +import type { SshTarget } from '../../../../shared/ssh-types' +import { useAppStore } from '@/store' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' +import type { SettingsSearchEntry } from './settings-search' +import { SshTargetCard } from './SshTargetCard' +import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm' + +export const SSH_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'SSH Connections', + description: 'Manage remote SSH targets.', + keywords: ['ssh', 'remote', 'server', 'connection', 'host'] + }, + { + title: 'Add SSH Target', + description: 'Add a new remote SSH target.', + keywords: ['ssh', 'add', 'new', 'target', 'host', 'server'] + }, + { + title: 'Import from SSH Config', + description: 'Import hosts from ~/.ssh/config.', + keywords: ['ssh', 'import', 'config', 'hosts'] + }, + { + title: 'Test Connection', + description: 'Test connectivity to an SSH target.', + keywords: ['ssh', 'test', 'connection', 'ping'] + } +] + +type SshPaneProps = Record + +export function SshPane(_props: SshPaneProps): React.JSX.Element { + const [targets, setTargets] = useState([]) + // Why: connection states are already hydrated and kept up-to-date by the + // global store (via useIpcEvents.ts). Reading from the store avoids + // duplicating the onStateChanged listener and per-target getState IPC calls. + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const [showForm, setShowForm] = useState(false) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState(EMPTY_FORM) + const [testing, setTesting] = useState(null) + const [pendingRemove, setPendingRemove] = useState<{ id: string; label: string } | null>(null) + + const loadTargets = useCallback(async () => { + try { + const result = (await window.api.ssh.listTargets()) as SshTarget[] + setTargets(result) + } catch { + toast.error('Failed to load SSH targets') + } + }, []) + + useEffect(() => { + void loadTargets() + }, [loadTargets]) + + const handleSave = async (): Promise => { + if (!form.host.trim() || !form.username.trim()) { + toast.error('Host and username are required') + return + } + + const port = parseInt(form.port, 10) + if (isNaN(port) || port < 1 || port > 65535) { + toast.error('Port must be between 1 and 65535') + return + } + + const target = { + label: form.label.trim() || `${form.username}@${form.host}`, + host: form.host.trim(), + port, + username: form.username.trim(), + ...(form.identityFile.trim() ? { identityFile: form.identityFile.trim() } : {}), + ...(form.proxyCommand.trim() ? { proxyCommand: form.proxyCommand.trim() } : {}), + ...(form.jumpHost.trim() ? { jumpHost: form.jumpHost.trim() } : {}) + } + + try { + if (editingId) { + await window.api.ssh.updateTarget({ id: editingId, updates: target }) + toast.success('Target updated') + } else { + await window.api.ssh.addTarget({ target }) + toast.success('Target added') + } + setShowForm(false) + setEditingId(null) + setForm(EMPTY_FORM) + await loadTargets() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save target') + } + } + + const handleRemove = async (id: string): Promise => { + try { + // Why: disconnect any non-disconnected connection, including transitional + // states (connecting, reconnecting, deploying-relay). Leaving these alive + // would orphan SSH connections with providers registered for a removed target. + const state = sshConnectionStates.get(id) + if (state && state.status !== 'disconnected') { + await window.api.ssh.disconnect({ targetId: id }) + } + await window.api.ssh.removeTarget({ id }) + toast.success('Target removed') + await loadTargets() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to remove target') + } + } + + const handleEdit = (target: SshTarget): void => { + setEditingId(target.id) + setForm({ + label: target.label, + host: target.host, + port: String(target.port), + username: target.username, + identityFile: target.identityFile ?? '', + proxyCommand: target.proxyCommand ?? '', + jumpHost: target.jumpHost ?? '' + }) + setShowForm(true) + } + + const handleConnect = async (targetId: string): Promise => { + try { + await window.api.ssh.connect({ targetId }) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Connection failed') + } + } + + const handleDisconnect = async (targetId: string): Promise => { + try { + await window.api.ssh.disconnect({ targetId }) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Disconnect failed') + } + } + + const handleTest = async (targetId: string): Promise => { + setTesting(targetId) + try { + const result = await window.api.ssh.testConnection({ targetId }) + if (result.success) { + toast.success('Connection successful') + } else { + toast.error(result.error ?? 'Connection test failed') + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Test failed') + } finally { + setTesting(null) + } + } + + const handleImport = async (): Promise => { + try { + const imported = (await window.api.ssh.importConfig()) as SshTarget[] + if (imported.length === 0) { + toast('No new hosts found in ~/.ssh/config') + } else { + toast.success(`Imported ${imported.length} host${imported.length > 1 ? 's' : ''}`) + } + await loadTargets() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Import failed') + } + } + + const cancelForm = (): void => { + setShowForm(false) + setEditingId(null) + setForm(EMPTY_FORM) + } + + return ( +
+ {/* Header row */} +
+
+

Targets

+

+ Add a remote host to connect to it in Orca. +

+
+
+ + {!showForm ? ( + + ) : null} +
+
+ + {/* Target list */} + {targets.length === 0 && !showForm ? ( +
+ No SSH targets configured. +
+ ) : ( +
+ {targets.map((target) => ( + void handleConnect(id)} + onDisconnect={(id) => void handleDisconnect(id)} + onTest={(id) => void handleTest(id)} + onEdit={handleEdit} + onRemove={(id) => setPendingRemove({ id, label: target.label })} + /> + ))} +
+ )} + + {/* Add/Edit form */} + {showForm ? ( + void handleSave()} + onCancel={cancelForm} + /> + ) : null} + + {/* Remove confirmation dialog */} + { + if (!open) { + setPendingRemove(null) + } + }} + > + + + Remove SSH Target + + This will remove the target and disconnect any active sessions. + + + + {pendingRemove ? ( +
+
{pendingRemove.label}
+
+ ) : null} + + + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/SshTargetCard.tsx b/src/renderer/src/components/settings/SshTargetCard.tsx new file mode 100644 index 00000000..972daaf7 --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetCard.tsx @@ -0,0 +1,158 @@ +import { Loader2, MonitorSmartphone, Pencil, Server, Trash2, Wifi, WifiOff } from 'lucide-react' +import type { + SshTarget, + SshConnectionState, + SshConnectionStatus +} from '../../../../shared/ssh-types' +import { Button } from '../ui/button' + +// ── Shared status helpers ──────────────────────────────────────────── + +export const STATUS_LABELS: Record = { + disconnected: 'Disconnected', + connecting: 'Connecting\u2026', + 'host-key-verification': 'Verifying host key\u2026', + 'auth-challenge': 'Authenticating\u2026', + 'auth-failed': 'Auth failed', + 'deploying-relay': 'Deploying relay\u2026', + connected: 'Connected', + reconnecting: 'Reconnecting\u2026', + 'reconnection-failed': 'Reconnection failed', + error: 'Error' +} + +export function statusColor(status: SshConnectionStatus): string { + switch (status) { + case 'connected': + return 'bg-emerald-500' + case 'connecting': + case 'host-key-verification': + case 'auth-challenge': + case 'deploying-relay': + case 'reconnecting': + return 'bg-yellow-500' + case 'auth-failed': + case 'reconnection-failed': + case 'error': + return 'bg-red-500' + default: + return 'bg-muted-foreground/40' + } +} + +export function isConnecting(status: SshConnectionStatus): boolean { + return ['connecting', 'host-key-verification', 'auth-challenge', 'deploying-relay'].includes( + status + ) +} + +// ── SshTargetCard ──────────────────────────────────────────────────── + +type SshTargetCardProps = { + target: SshTarget + state: SshConnectionState | undefined + testing: boolean + onConnect: (targetId: string) => void + onDisconnect: (targetId: string) => void + onTest: (targetId: string) => void + onEdit: (target: SshTarget) => void + onRemove: (targetId: string) => void +} + +export function SshTargetCard({ + target, + state, + testing, + onConnect, + onDisconnect, + onTest, + onEdit, + onRemove +}: SshTargetCardProps): React.JSX.Element { + const status: SshConnectionStatus = state?.status ?? 'disconnected' + + return ( +
+ + +
+
+ {target.label} + + {STATUS_LABELS[status]} +
+

+ {target.username}@{target.host}:{target.port} + {target.identityFile ? ` \u2022 ${target.identityFile}` : ''} +

+ {state?.error ? ( +

{state.error}

+ ) : null} +
+ +
+ {status === 'connected' ? ( + + ) : isConnecting(status) ? ( + + ) : ( + <> + + + + )} + + + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/SshTargetForm.tsx b/src/renderer/src/components/settings/SshTargetForm.tsx new file mode 100644 index 00000000..f8765ef4 --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetForm.tsx @@ -0,0 +1,129 @@ +import { FileKey } from 'lucide-react' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' + +export type EditingTarget = { + label: string + host: string + port: string + username: string + identityFile: string + proxyCommand: string + jumpHost: string +} + +export const EMPTY_FORM: EditingTarget = { + label: '', + host: '', + port: '22', + username: '', + identityFile: '', + proxyCommand: '', + jumpHost: '' +} + +type SshTargetFormProps = { + editingId: string | null + form: EditingTarget + onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void + onSave: () => void + onCancel: () => void +} + +export function SshTargetForm({ + editingId, + form, + onFormChange, + onSave, + onCancel +}: SshTargetFormProps): React.JSX.Element { + return ( +
+

{editingId ? 'Edit SSH Target' : 'New SSH Target'}

+ +
+
+ + onFormChange((f) => ({ ...f, label: e.target.value }))} + placeholder="My Server" + /> +
+
+ + onFormChange((f) => ({ ...f, host: e.target.value }))} + placeholder="192.168.1.100 or server.example.com" + /> +
+
+ + onFormChange((f) => ({ ...f, username: e.target.value }))} + placeholder="deploy" + /> +
+
+ + onFormChange((f) => ({ ...f, port: e.target.value }))} + placeholder="22" + min={1} + max={65535} + /> +
+
+ + onFormChange((f) => ({ ...f, identityFile: e.target.value }))} + placeholder="~/.ssh/id_ed25519 (leave empty for SSH agent)" + /> +

+ Optional. SSH agent is used by default. +

+
+
+ + onFormChange((f) => ({ ...f, proxyCommand: e.target.value }))} + placeholder="e.g. cloudflared access ssh --hostname %h" + /> +

+ Optional. Used for tunneling (e.g. Cloudflare Access, ProxyCommand). +

+
+
+ + onFormChange((f) => ({ ...f, jumpHost: e.target.value }))} + placeholder="bastion.example.com" + /> +

+ Optional. Equivalent to ProxyJump / ssh -J. +

+
+
+ +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index fa35f749..b36720b1 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -1,10 +1,6 @@ -/* eslint-disable max-lines -- Why: AddRepoDialog owns a multi-step flow (add/clone/setup) with - clone progress, abort handling, and worktree setup — splitting further would scatter - tightly coupled step transitions across files. */ - import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { FolderOpen, GitBranchPlus, Settings, ArrowLeft, Globe, Folder } from 'lucide-react' +import { FolderOpen, GitBranchPlus, Settings, ArrowLeft, Globe, Monitor } from 'lucide-react' import { useAppStore } from '@/store' import { Dialog, @@ -14,9 +10,9 @@ 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 { LinkedWorktreeItem } from './LinkedWorktreeItem' +import { RemoteStep, CloneStep, useRemoteRepo } from './AddRepoSteps' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo, Worktree } from '../../../../shared/types' @@ -31,7 +27,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const setActiveView = useAppStore((s) => s.setActiveView) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) - const [step, setStep] = useState<'add' | 'clone' | 'setup'>('add') + const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'setup'>('add') const [addedRepo, setAddedRepo] = useState(null) const [isAdding, setIsAdding] = useState(false) const [cloneUrl, setCloneUrl] = useState('') @@ -41,12 +37,23 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const [cloneProgress, setCloneProgress] = useState<{ phase: string; percent: number } | null>( null ) - // Why: track a monotonically increasing ID so that when the user closes the - // dialog or navigates away during a clone, the stale completion callback can - // detect it was superseded and bail out instead of corrupting dialog state. + + // Why: monotonic ID so stale clone callbacks can detect they were superseded. const cloneGenRef = useRef(0) - // Subscribe to clone progress events while cloning is active + const { + sshTargets, + selectedTargetId, + remotePath, + remoteError, + isAddingRemote, + setSelectedTargetId, + setRemotePath, + setRemoteError, + resetRemoteState, + handleOpenRemoteStep, + handleAddRemoteRepo + } = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal) useEffect(() => { if (!isCloning) { return @@ -61,8 +68,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { return worktreesByRepo[repoId] ?? [] }, [worktreesByRepo, repoId]) - // Why: sort by recent activity (lastActivityAt) with alphabetical fallback for - // worktrees not yet opened in Orca. Matches buildWorktreeComparator behavior. + // Why: sort by recent activity with alphabetical fallback. const sortedWorktrees = useMemo(() => { return [...worktrees].sort((a, b) => { if (a.lastActivityAt !== b.lastActivityAt) { @@ -87,19 +93,17 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setIsCloning(false) setCloneError(null) setCloneProgress(null) - }, []) + resetRemoteState() + }, [resetRemoteState]) - // Why: reset all local state when the dialog closes for any reason — - // whether via onOpenChange, closeModal() from code, or activeModal - // being replaced by another modal. Without this, reopening the dialog - // can show a stale step/repo from the previous session. + // Why: reset state on close so reopening doesn't show stale step/repo. useEffect(() => { if (!isOpen) { resetState() } }, [isOpen, resetState]) - const isInputStep = step === 'add' || step === 'clone' + const isInputStep = step === 'add' || step === 'clone' || step === 'remote' const handleBrowse = useCallback(async () => { setIsAdding(true) @@ -110,12 +114,9 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { await fetchWorktrees(repo.id) setStep('setup') } else if (repo) { - // Why: non-git folders have no worktrees, so step 2 is irrelevant. Close - // the modal after the folder is added. + // Why: non-git folders have no worktrees — close immediately. closeModal() } - // null = user cancelled the picker, or the non-git-folder confirmation - // dialog took over (which replaces activeModal, closing this dialog). } finally { setIsAdding(false) } @@ -149,11 +150,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { return } toast.success('Repository cloned', { description: repo.displayName }) - // Why: eagerly upsert the cloned repo in the store so that step 2's - // "Create worktree" button finds it in eligibleRepos immediately, - // without waiting for the async repos:changed IPC event. This also - // handles the case where a folder repo was upgraded to git by the - // clone handler — the existing entry needs its kind updated. + // Why: eagerly upsert so step 2 finds the repo before the IPC event. const state = useAppStore.getState() const existingIdx = state.repos.findIndex((r) => r.id === repo.id) if (existingIdx === -1) { @@ -201,34 +198,23 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setActiveView('settings') }, [closeModal, openSettingsTarget, setActiveView, repoId]) - const handleBack = useCallback(() => { - cloneGenRef.current++ - void window.api.repos.cloneAbort() - setStep('add') - setAddedRepo(null) - setCloneUrl('') - setCloneDestination('') - setIsCloning(false) - setCloneError(null) - setCloneProgress(null) - }, []) - - const handleOpenChange = useCallback( - (open: boolean) => { - if (!open) { - closeModal() - resetState() - } - }, - [closeModal, resetState] - ) + // Why: handleBack reuses resetState which already aborts clones and resets all fields. + const handleBack = resetState return ( - + { + if (!open) { + closeModal() + resetState() + } + }} + > {/* Step indicator row — back button (step 2 only), dots, X is rendered by DialogContent */}
- {step === 'clone' && ( + {(step === 'clone' || step === 'remote') && ( @@ -286,96 +272,67 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { + +
+ ) : step === 'remote' ? ( + { + setSelectedTargetId(id) + setRemoteError(null) + }} + onRemotePathChange={(value) => { + setRemotePath(value) + setRemoteError(null) + }} + onAdd={handleAddRemoteRepo} + /> ) : step === 'clone' ? ( - <> - - Clone from URL - Enter the Git URL and choose where to clone it. - - -
-
- - { - setCloneUrl(e.target.value) - setCloneError(null) - }} - placeholder="https://github.com/user/repo.git" - className="h-8 text-xs" - disabled={isCloning} - autoFocus - /> -
- -
- -
- { - setCloneDestination(e.target.value) - setCloneError(null) - }} - placeholder="/path/to/destination" - className="h-8 text-xs flex-1" - disabled={isCloning} - /> - -
-
- - {cloneError &&

{cloneError}

} - - - - {/* Why: progress bar lives below the button so it doesn't push the - button down when it appears mid-clone. */} - {isCloning && cloneProgress && ( -
-
- {cloneProgress.phase} - {cloneProgress.percent}% -
-
-
-
-
- )} -
- + { + setCloneUrl(value) + setCloneError(null) + }} + onDestChange={(value) => { + setCloneDestination(value) + setCloneError(null) + }} + onPickDestination={handlePickDestination} + onClone={handleClone} + /> ) : ( <> @@ -424,7 +381,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { variant="ghost" size="sm" className="text-xs" - onClick={() => handleOpenChange(false)} + onClick={() => { + closeModal() + resetState() + }} > Skip diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx new file mode 100644 index 00000000..fd483ee5 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -0,0 +1,367 @@ +/** + * Step views for AddRepoDialog: Clone, Remote, and Setup. + * + * Why extracted: keeps AddRepoDialog.tsx under the 400-line oxlint limit + * by moving the presentational JSX for each wizard step into separate components + * while the parent retains all state and handlers. + */ +import React, { useCallback, useRef, useState } from 'react' +import { toast } from 'sonner' +import { Folder, FolderOpen } from 'lucide-react' +import { useAppStore } from '@/store' +import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { RemoteFileBrowser } from './RemoteFileBrowser' +import type { Repo } from '../../../../shared/types' +import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' + +// ── Remote repo hook ──────────────────────────────────────────────── + +export function useRemoteRepo( + fetchWorktrees: (repoId: string) => Promise, + setStep: (step: 'add' | 'clone' | 'remote' | 'setup') => void, + setAddedRepo: (repo: Repo | null) => void, + closeModal: () => void +) { + const [sshTargets, setSshTargets] = useState<(SshTarget & { state?: SshConnectionState })[]>([]) + const [selectedTargetId, setSelectedTargetId] = useState(null) + const [remotePath, setRemotePath] = useState('~/') + const [remoteError, setRemoteError] = useState(null) + const [isAddingRemote, setIsAddingRemote] = useState(false) + const remoteGenRef = useRef(0) + + const resetRemoteState = useCallback(() => { + remoteGenRef.current++ + setSshTargets([]) + setSelectedTargetId(null) + setRemotePath('~/') + setRemoteError(null) + setIsAddingRemote(false) + }, []) + + const handleOpenRemoteStep = useCallback(async () => { + const gen = ++remoteGenRef.current + setStep('remote') + try { + const targets = (await window.api.ssh.listTargets()) as SshTarget[] + if (gen !== remoteGenRef.current) { + return + } + const withState = await Promise.all( + targets.map(async (t) => { + const state = (await window.api.ssh.getState({ + targetId: t.id + })) as SshConnectionState | null + return { ...t, state: state ?? undefined } + }) + ) + if (gen !== remoteGenRef.current) { + return + } + setSshTargets(withState) + const connected = withState.find((t) => t.state?.status === 'connected') + if (connected) { + setSelectedTargetId(connected.id) + } + } catch { + if (gen !== remoteGenRef.current) { + return + } + setSshTargets([]) + } + }, [setStep]) + + const handleAddRemoteRepo = useCallback(async () => { + if (!selectedTargetId || !remotePath.trim()) { + return + } + + setIsAddingRemote(true) + setRemoteError(null) + try { + const repo = (await window.api.repos.addRemote({ + connectionId: selectedTargetId, + remotePath: remotePath.trim() + })) as Repo + + const state = useAppStore.getState() + const existingIdx = state.repos.findIndex((r) => r.id === repo.id) + if (existingIdx === -1) { + useAppStore.setState({ repos: [...state.repos, repo] }) + } else { + const updated = [...state.repos] + updated[existingIdx] = repo + useAppStore.setState({ repos: updated }) + } + + toast.success('Remote repository added', { description: repo.displayName }) + setAddedRepo(repo) + await fetchWorktrees(repo.id) + setStep('setup') + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes('Not a valid git repository')) { + // Why: match the local add-repo flow — show confirmation dialog so + // users understand git features will be unavailable, rather than + // silently adding as a folder. + closeModal() + useAppStore.getState().openModal('confirm-non-git-folder', { + folderPath: remotePath.trim(), + connectionId: selectedTargetId + }) + return + } + setRemoteError(message) + } finally { + setIsAddingRemote(false) + } + }, [selectedTargetId, remotePath, fetchWorktrees, setStep, setAddedRepo, closeModal]) + + return { + sshTargets, + selectedTargetId, + remotePath, + remoteError, + isAddingRemote, + setSelectedTargetId, + setRemotePath, + setRemoteError, + resetRemoteState, + handleOpenRemoteStep, + handleAddRemoteRepo + } +} + +// ── Remote step ────────────────────────────────────────────────────── + +type RemoteStepProps = { + sshTargets: (SshTarget & { state?: SshConnectionState })[] + selectedTargetId: string | null + remotePath: string + remoteError: string | null + isAddingRemote: boolean + onSelectTarget: (id: string) => void + onRemotePathChange: (value: string) => void + onAdd: () => void +} + +export function RemoteStep({ + sshTargets, + selectedTargetId, + remotePath, + remoteError, + isAddingRemote, + onSelectTarget, + onRemotePathChange, + onAdd +}: RemoteStepProps): React.JSX.Element { + const [browsing, setBrowsing] = useState(false) + + if (browsing && selectedTargetId) { + return ( + <> + + Browse remote filesystem + + Navigate to a directory and click Select to choose it. + + + { + onRemotePathChange(path) + setBrowsing(false) + }} + onCancel={() => setBrowsing(false)} + /> + + ) + } + + return ( + <> + + Open remote repo + + Choose a connected SSH target and enter the path to a Git repository. + + + +
+
+ + {sshTargets.length === 0 ? ( +

+ No SSH targets configured. Add one in Settings first. +

+ ) : ( +
+ {sshTargets.map((target) => { + const isConnected = target.state?.status === 'connected' + const isSelected = selectedTargetId === target.id + return ( + + ) + })} +
+ )} +
+ +
+ +
+ onRemotePathChange(e.target.value)} + placeholder="/home/user/project" + className="h-8 text-xs flex-1" + disabled={isAddingRemote} + /> + +
+
+ + {remoteError &&

{remoteError}

} + + +
+ + ) +} + +// ── Clone step ─────────────────────────────────────────────────────── + +type CloneStepProps = { + cloneUrl: string + cloneDestination: string + cloneError: string | null + cloneProgress: { phase: string; percent: number } | null + isCloning: boolean + onUrlChange: (value: string) => void + onDestChange: (value: string) => void + onPickDestination: () => void + onClone: () => void +} + +export function CloneStep({ + cloneUrl, + cloneDestination, + cloneError, + cloneProgress, + isCloning, + onUrlChange, + onDestChange, + onPickDestination, + onClone +}: CloneStepProps): React.JSX.Element { + return ( + <> + + Clone from URL + Enter the Git URL and choose where to clone it. + + +
+
+ + onUrlChange(e.target.value)} + placeholder="https://github.com/user/repo.git" + className="h-8 text-xs" + disabled={isCloning} + autoFocus + /> +
+ +
+ +
+ onDestChange(e.target.value)} + placeholder="/path/to/destination" + className="h-8 text-xs flex-1" + disabled={isCloning} + /> + +
+
+ + {cloneError &&

{cloneError}

} + + + + {/* Why: progress bar lives below the button so it doesn't push the + button down when it appears mid-clone. */} + {isCloning && cloneProgress && ( +
+
+ {cloneProgress.phase} + {cloneProgress.percent}% +
+
+
+
+
+ )} +
+ + ) +} diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index e626d1e8..6faed63d 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -18,13 +18,31 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { const isOpen = activeModal === 'confirm-non-git-folder' const folderPath = typeof modalData.folderPath === 'string' ? modalData.folderPath : '' + const connectionId = typeof modalData.connectionId === 'string' ? modalData.connectionId : '' const handleConfirm = useCallback(() => { - if (folderPath) { + if (connectionId && folderPath) { + void (async () => { + try { + const repo = await window.api.repos.addRemote({ + connectionId, + remotePath: folderPath, + kind: 'folder' + }) + const state = useAppStore.getState() + if (!state.repos.some((r) => r.id === repo.id)) { + useAppStore.setState({ repos: [...state.repos, repo] }) + } + await state.fetchWorktrees(repo.id) + } catch { + // Best-effort — the toast from the store handles errors + } + })() + } else if (folderPath) { void addNonGitFolder(folderPath) } closeModal() - }, [addNonGitFolder, closeModal, folderPath]) + }, [addNonGitFolder, closeModal, folderPath, connectionId]) const handleOpenChange = useCallback( (open: boolean) => { diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx new file mode 100644 index 00000000..b84c9841 --- /dev/null +++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx @@ -0,0 +1,201 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { ChevronRight, Folder, File, ArrowUp, LoaderCircle, Home } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +type DirEntry = { + name: string + isDirectory: boolean +} + +type RemoteFileBrowserProps = { + targetId: string + initialPath?: string + onSelect: (path: string) => void + onCancel: () => void +} + +export function RemoteFileBrowser({ + targetId, + initialPath = '~', + onSelect, + onCancel +}: RemoteFileBrowserProps): React.JSX.Element { + const [resolvedPath, setResolvedPath] = useState('') + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedName, setSelectedName] = useState(null) + const genRef = useRef(0) + + const loadDir = useCallback( + async (dirPath: string) => { + const gen = ++genRef.current + setLoading(true) + setError(null) + setSelectedName(null) + try { + const result = await window.api.ssh.browseDir({ targetId, dirPath }) + if (gen !== genRef.current) { + return + } + setResolvedPath(result.resolvedPath) + setEntries(result.entries) + } catch (err) { + if (gen !== genRef.current) { + return + } + setError(err instanceof Error ? err.message : String(err)) + setEntries([]) + } finally { + if (gen === genRef.current) { + setLoading(false) + } + } + }, + [targetId] + ) + + useEffect(() => { + loadDir(initialPath) + }, [loadDir, initialPath]) + + const navigateTo = useCallback( + (name: string) => { + const next = resolvedPath === '/' ? `/${name}` : `${resolvedPath}/${name}` + loadDir(next) + }, + [resolvedPath, loadDir] + ) + + const navigateUp = useCallback(() => { + if (resolvedPath === '/') { + return + } + const parent = resolvedPath.replace(/\/[^/]+\/?$/, '') || '/' + loadDir(parent) + }, [resolvedPath, loadDir]) + + const handleDoubleClick = useCallback( + (entry: DirEntry) => { + if (entry.isDirectory) { + navigateTo(entry.name) + } + }, + [navigateTo] + ) + + const handleSelect = useCallback(() => { + if (selectedName) { + const full = resolvedPath === '/' ? `/${selectedName}` : `${resolvedPath}/${selectedName}` + onSelect(full) + } else { + onSelect(resolvedPath) + } + }, [resolvedPath, selectedName, onSelect]) + + const pathSegments = resolvedPath.split('/').filter(Boolean) + + return ( +
+ {/* Breadcrumb bar */} +
+ + +
+ + {pathSegments.map((segment, i) => ( + + + + + ))} +
+
+ + {/* File listing */} +
+
+ {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : entries.length === 0 ? ( +
+

Empty directory

+
+ ) : ( + entries.map((entry) => ( + + )) + )} +
+
+ + {/* Footer */} +
+

+ {selectedName ? `${resolvedPath}/${selectedName}` : resolvedPath} +

+
+ + +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx new file mode 100644 index 00000000..2be28b72 --- /dev/null +++ b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx @@ -0,0 +1,111 @@ +import { useCallback, useState } from 'react' +import { toast } from 'sonner' +import { Globe, Loader2, WifiOff } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { statusColor } from '@/components/settings/SshTargetCard' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +type SshDisconnectedDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + targetId: string + targetLabel: string + status: SshConnectionStatus +} + +const STATUS_MESSAGES: Partial> = { + disconnected: 'This remote repository is not connected.', + reconnecting: 'Reconnecting to the remote host...', + 'reconnection-failed': 'Reconnection to the remote host failed.', + error: 'The connection to the remote host encountered an error.', + 'auth-failed': 'Authentication to the remote host failed.' +} + +function isReconnectable(status: SshConnectionStatus): boolean { + return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status) +} + +export function SshDisconnectedDialog({ + open, + onOpenChange, + targetId, + targetLabel, + status +}: SshDisconnectedDialogProps): React.JSX.Element { + const [connecting, setConnecting] = useState(false) + + const handleReconnect = useCallback(async () => { + setConnecting(true) + try { + await window.api.ssh.connect({ targetId }) + onOpenChange(false) + toast.success(`Reconnected to ${targetLabel}`) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Reconnection failed') + } finally { + setConnecting(false) + } + }, [targetId, targetLabel, onOpenChange]) + + const isConnecting = connecting || status === 'reconnecting' || status === 'connecting' + const message = isConnecting + ? 'Reconnecting to the remote host...' + : (STATUS_MESSAGES[status] ?? 'This remote repository is not connected.') + const showReconnect = isReconnectable(status) + + return ( + + + + + {isConnecting ? ( + + ) : ( + + )} + {isConnecting ? 'Reconnecting...' : 'SSH Disconnected'} + + {message} + + +
+ +
+ {targetLabel} +
+ {isConnecting ? ( + + ) : ( + + )} +
+ + + + {showReconnect && ( + + )} + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 0e99a7b4..04d3fe45 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -1,92 +1,35 @@ -/* eslint-disable max-lines */ -import React, { useEffect, useMemo, useCallback } from 'react' +import React, { useEffect, useMemo, useCallback, useState } from 'react' import { useAppStore } from '@/store' import { Badge } from '@/components/ui/badge' -import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' -import { Bell, GitMerge, LoaderCircle, CircleDot, CircleCheck, CircleX } from 'lucide-react' +import { Bell, GitMerge, LoaderCircle, CircleCheck, CircleX, Globe, WifiOff } from 'lucide-react' import StatusIndicator from './StatusIndicator' import CacheTimer from './CacheTimer' -import CommentMarkdown from './CommentMarkdown' import WorktreeContextMenu from './WorktreeContextMenu' +import { SshDisconnectedDialog } from './SshDisconnectedDialog' import { cn } from '@/lib/utils' import { getWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' -import type { - Worktree, - Repo, - PRInfo, - IssueInfo, - PRState, - CheckStatus, - GitConflictOperation, - TerminalTab -} from '../../../../shared/types' - -function branchDisplayName(branch: string): string { - return branch.replace(/^refs\/heads\//, '') -} - -function prStateLabel(state: PRState): string { - return state.charAt(0).toUpperCase() + state.slice(1) -} - -function checksLabel(status: CheckStatus): string { - switch (status) { - case 'success': - return 'Passing' - case 'failure': - return 'Failing' - case 'pending': - return 'Pending' - default: - return '' - } -} - -const CONFLICT_OPERATION_LABELS: Record, string> = { - merge: 'Merging', - rebase: 'Rebasing', - 'cherry-pick': 'Cherry-picking' -} - -// ── Stable empty array for tabs fallback ───────────────────────── -const EMPTY_TABS: TerminalTab[] = [] -const EMPTY_BROWSER_TABS: { id: string }[] = [] +import type { Worktree, Repo, PRInfo, IssueInfo } from '../../../../shared/types' +import { + branchDisplayName, + checksLabel, + CONFLICT_OPERATION_LABELS, + EMPTY_TABS, + EMPTY_BROWSER_TABS, + FilledBellIcon +} from './WorktreeCardHelpers' +import { IssueSection, PrSection, CommentSection } from './WorktreeCardMeta' type WorktreeCardProps = { worktree: Worktree repo: Repo | undefined isActive: boolean hideRepoBadge?: boolean - /** 1–9 hint badge shown when the user holds the platform modifier key. */ + /** 1-9 hint badge shown when the user holds the platform modifier key. */ hintNumber?: number } -function FilledBellIcon({ className }: { className?: string }): React.JSX.Element { - return ( - - - - ) -} - -function PullRequestIcon({ className }: { className?: string }): React.JSX.Element { - return ( - - - - ) -} - const WorktreeCard = React.memo(function WorktreeCard({ worktree, repo, @@ -131,6 +74,22 @@ const WorktreeCard = React.memo(function WorktreeCard({ const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) const conflictOperation = useAppStore((s) => s.gitConflictOperationByWorktree[worktree.id]) + // SSH disconnected state + const sshStatus = useAppStore((s) => { + if (!repo?.connectionId) { + return null + } + const state = s.sshConnectionStates.get(repo.connectionId) + return state?.status ?? 'disconnected' + }) + const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' + const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false) + // Why: read the target label from the store (populated during hydration in + // useIpcEvents.ts) instead of calling listTargets IPC per card instance. + const sshTargetLabel = useAppStore((s) => + repo?.connectionId ? (s.sshTargetLabels.get(repo.connectionId) ?? '') : '' + ) + // ── GRANULAR selectors: only subscribe to THIS worktree's data ── const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS) const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS) @@ -189,14 +148,22 @@ const WorktreeCard = React.memo(function WorktreeCard({ return () => clearInterval(interval) }, [repo, isFolder, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue]) - // Stable click handler – ignore clicks that are really text selections + // Stable click handler – ignore clicks that are really text selections. + // Why: if the SSH connection is down, show a reconnect dialog instead of + // activating the worktree — all remote operations would fail anyway. const handleClick = useCallback(() => { const selection = window.getSelection() if (selection && selection.toString().length > 0) { return } + // Why: always activate the worktree so the user can see terminal history, + // editor state, etc. even when SSH is disconnected. Show the reconnect + // dialog as a non-blocking overlay rather than a gate. setActiveWorktree(worktree.id) - }, [worktree.id, setActiveWorktree]) + if (isSshDisconnected) { + setShowDisconnectedDialog(true) + } + }, [worktree.id, setActiveWorktree, isSshDisconnected]) const handleDoubleClick = useCallback(() => { openModal('edit-meta', { @@ -219,324 +186,225 @@ const WorktreeCard = React.memo(function WorktreeCard({ const unreadTooltip = worktree.isUnread ? 'Mark read' : 'Mark unread' return ( - -
- {isDeleting && ( -
-
- - Deleting… + <> + +
+ {isDeleting && ( +
+
+ + Deleting… +
-
- )} + )} - {/* Cmd+N hint badge — decorative only, shown when the user holds the + {/* Cmd+N hint badge — decorative only, shown when the user holds the platform modifier key for discoverability of Cmd+1–9 shortcuts. Why centered on the left edge: placing it at the top clipped the glyph against the card bounds on some sizes, while mid-card keeps the badge fully visible without competing with the title row. */} - {hintNumber != null && ( - - )} + {hintNumber != null && ( + + )} - {/* Status indicator on the left */} - {(cardProps.includes('status') || cardProps.includes('unread')) && ( -
- {cardProps.includes('status') && } + {/* Status indicator on the left */} + {(cardProps.includes('status') || cardProps.includes('unread')) && ( +
+ {cardProps.includes('status') && } - {cardProps.includes('unread') && ( - - - - - - {unreadTooltip} - - - )} -
- )} - - {/* Content area */} -
- {/* Header row: Title and Checks */} -
-
-
- {worktree.displayName} -
- - {/* Why: the primary worktree (the original clone directory) cannot be - deleted via `git worktree remove`. Placing this badge next to the - name makes it immediately visible and avoids confusion with the - branch name "main" shown below. */} - {worktree.isMainWorktree && !isFolder && ( + {cardProps.includes('unread') && ( - - primary - + {worktree.isUnread ? ( + + ) : ( + + )} + - Primary worktree (original clone directory) + {unreadTooltip} )}
+ )} - {/* CI Checks & PR state on the right */} - {cardProps.includes('ci') && pr && pr.checksStatus !== 'neutral' && ( -
+ {/* Content area */} +
+ {/* Header row: Title and Checks */} +
+
+
+ {worktree.displayName} +
+ + {/* Why: the primary worktree (the original clone directory) cannot be + deleted via `git worktree remove`. Placing this badge next to the + name makes it immediately visible and avoids confusion with the + branch name "main" shown below. */} + {worktree.isMainWorktree && !isFolder && ( + + + + primary + + + + Primary worktree (original clone directory) + + + )} +
+ + {/* CI Checks & PR state on the right */} + {cardProps.includes('ci') && pr && pr.checksStatus !== 'neutral' && ( +
+ + + + {pr.checksStatus === 'success' && ( + + )} + {pr.checksStatus === 'failure' && ( + + )} + {pr.checksStatus === 'pending' && ( + + )} + + + + CI checks {checksLabel(pr.checksStatus).toLowerCase()} + + +
+ )} +
+ + {/* Subtitle row: Repo badge + Branch */} +
+ {repo && !hideRepoBadge && ( +
+
+ + {repo.displayName} + +
+ )} + + {repo?.connectionId && ( - - {pr.checksStatus === 'success' && ( - - )} - {pr.checksStatus === 'failure' && ( - - )} - {pr.checksStatus === 'pending' && ( - + + {isSshDisconnected ? ( + + ) : ( + )} - CI checks {checksLabel(pr.checksStatus).toLowerCase()} + {isSshDisconnected ? 'SSH disconnected' : 'Remote repository via SSH'} -
- )} -
+ )} - {/* Subtitle row: Repo badge + Branch */} -
- {repo && !hideRepoBadge && ( -
-
- - {repo.displayName} + {isFolder ? ( + + {repo ? getRepoKindLabel(repo) : 'Folder'} + + ) : ( + + {branch} -
- )} + )} - {isFolder ? ( - - {repo ? getRepoKindLabel(repo) : 'Folder'} - - ) : ( - - {branch} - - )} - - {/* Why: the conflict operation (merge/rebase/cherry-pick) is the + {/* Why: the conflict operation (merge/rebase/cherry-pick) is the only signal that the worktree is in an incomplete operation state. Showing it on the card lets the user spot worktrees that need attention without switching to them first. */} - {conflictOperation && conflictOperation !== 'unknown' && ( - - - {CONFLICT_OPERATION_LABELS[conflictOperation]} - - )} - - -
- - {/* Meta section: Issue / PR Links / Comment - ⚠ Layout coupling: the padding (py-0.5, mt-0.5), gap-[3px], and - line heights here are used to derive the size estimates in - WorktreeList's estimateSize. The comment row's estimate is - dynamic (based on content length + newlines). Update the - estimate function if changing spacing or line-height. */} - {((cardProps.includes('issue') && issue) || - (cardProps.includes('pr') && pr) || - (cardProps.includes('comment') && worktree.comment)) && ( -
- {cardProps.includes('issue') && issue && ( - - -
- -
- - #{issue.number} - - - {issue.title} - -
-
-
- -
- #{issue.number} {issue.title} -
-
- State: {issue.state === 'open' ? 'Open' : 'Closed'} -
- {issue.labels.length > 0 && ( -
- {issue.labels.map((l) => ( - - {l} - - ))} -
- )} - - View on GitHub - -
-
+ {conflictOperation && conflictOperation !== 'unknown' && ( + + + {CONFLICT_OPERATION_LABELS[conflictOperation]} + )} - {cardProps.includes('pr') && pr && ( - - - - - -
- #{pr.number} {pr.title} -
-
- State: {prStateLabel(pr.state)} - {pr.checksStatus !== 'neutral' && ( - Checks: {checksLabel(pr.checksStatus)} - )} -
- e.stopPropagation()} - > - View on GitHub - -
-
- )} - - {cardProps.includes('comment') && worktree.comment && ( - - - - - - - - - )} +
- )} + + {/* Meta section: Issue / PR Links / Comment + Layout coupling: spacing here is used to derive size estimates in + WorktreeList's estimateSize. Update that function if changing spacing. */} + {((cardProps.includes('issue') && issue) || + (cardProps.includes('pr') && pr) || + (cardProps.includes('comment') && worktree.comment)) && ( +
+ {cardProps.includes('issue') && issue && ( + + )} + {cardProps.includes('pr') && pr && } + {cardProps.includes('comment') && worktree.comment && ( + + )} +
+ )} +
-
- + + + {repo?.connectionId && ( + + )} + ) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardHelpers.tsx b/src/renderer/src/components/sidebar/WorktreeCardHelpers.tsx new file mode 100644 index 00000000..0794635e --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardHelpers.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import type { + PRState, + CheckStatus, + GitConflictOperation, + TerminalTab +} from '../../../../shared/types' + +// ── Pure helper functions ──────────────────────────────────────────── + +export function branchDisplayName(branch: string): string { + return branch.replace(/^refs\/heads\//, '') +} + +export function prStateLabel(state: PRState): string { + return state.charAt(0).toUpperCase() + state.slice(1) +} + +export function checksLabel(status: CheckStatus): string { + switch (status) { + case 'success': + return 'Passing' + case 'failure': + return 'Failing' + case 'pending': + return 'Pending' + default: + return '' + } +} + +export const CONFLICT_OPERATION_LABELS: Record, string> = { + merge: 'Merging', + rebase: 'Rebasing', + 'cherry-pick': 'Cherry-picking' +} + +// ── Stable empty arrays for tabs fallback ──────────────────────────── + +export const EMPTY_TABS: TerminalTab[] = [] +export const EMPTY_BROWSER_TABS: { id: string }[] = [] + +// ── SVG icon components ────────────────────────────────────────────── + +export function FilledBellIcon({ className }: { className?: string }): React.JSX.Element { + return ( + + + + ) +} + +export function PullRequestIcon({ className }: { className?: string }): React.JSX.Element { + return ( + + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx new file mode 100644 index 00000000..a0ec8dbf --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -0,0 +1,158 @@ +/** + * Issue, PR, and Comment meta sections for WorktreeCard. + * + * Why extracted: keeps WorktreeCard.tsx under the 400-line oxlint limit + * while co-locating the HoverCard presentation for each metadata type. + */ +import React from 'react' +import { Badge } from '@/components/ui/badge' +import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card' +import { CircleDot } from 'lucide-react' +import { cn } from '@/lib/utils' +import CommentMarkdown from './CommentMarkdown' +import { PullRequestIcon, prStateLabel, checksLabel } from './WorktreeCardHelpers' +import type { PRInfo, IssueInfo } from '../../../../shared/types' + +// ── Issue section ──────────────────────────────────────────────────── + +type IssueSectionProps = { + issue: IssueInfo + onClick: (e: React.MouseEvent) => void +} + +export function IssueSection({ issue, onClick }: IssueSectionProps): React.JSX.Element { + return ( + + +
+ +
+ #{issue.number} + + {issue.title} + +
+
+
+ +
+ #{issue.number} {issue.title} +
+
+ State: {issue.state === 'open' ? 'Open' : 'Closed'} +
+ {issue.labels.length > 0 && ( +
+ {issue.labels.map((l) => ( + + {l} + + ))} +
+ )} + + View on GitHub + +
+
+ ) +} + +// ── PR section ─────────────────────────────────────────────────────── + +type PrSectionProps = { + pr: PRInfo + onClick: (e: React.MouseEvent) => void +} + +export function PrSection({ pr, onClick }: PrSectionProps): React.JSX.Element { + return ( + + + + + +
+ #{pr.number} {pr.title} +
+
+ State: {prStateLabel(pr.state)} + {pr.checksStatus !== 'neutral' && Checks: {checksLabel(pr.checksStatus)}} +
+ e.stopPropagation()} + > + View on GitHub + +
+
+ ) +} + +// ── Comment section ────────────────────────────────────────────────── + +type CommentSectionProps = { + comment: string + onDoubleClick: (e: React.MouseEvent) => void +} + +export function CommentSection({ comment, onDoubleClick }: CommentSectionProps): React.JSX.Element { + return ( + + + + + + + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/bell-detector.ts b/src/renderer/src/components/terminal-pane/bell-detector.ts new file mode 100644 index 00000000..a5f62e26 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/bell-detector.ts @@ -0,0 +1,61 @@ +/** + * Stateful BEL detector that correctly ignores BEL (0x07) bytes + * occurring inside OSC escape sequences. + * + * Why stateful: PTY data arrives in arbitrary chunks, so an OSC sequence + * may span multiple calls. The detector tracks in-progress escape state + * across invocations so a BEL used as an OSC terminator is never + * misinterpreted as a terminal bell. + */ +export function createBellDetector(): (data: string) => boolean { + let pendingEscape = false + let inOsc = false + let pendingOscEscape = false + + return function chunkContainsBell(data: string): boolean { + for (let i = 0; i < data.length; i += 1) { + const char = data[i] + + if (inOsc) { + if (pendingOscEscape) { + pendingOscEscape = char === '\x1b' + if (char === '\\') { + inOsc = false + pendingOscEscape = false + } + continue + } + + if (char === '\x07') { + inOsc = false + continue + } + + pendingOscEscape = char === '\x1b' + continue + } + + if (pendingEscape) { + pendingEscape = false + if (char === ']') { + inOsc = true + pendingOscEscape = false + } else if (char === '\x1b') { + pendingEscape = true + } + continue + } + + if (char === '\x1b') { + pendingEscape = true + continue + } + + if (char === '\x07') { + return true + } + } + + return false + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 15914f7f..6c994224 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -146,10 +146,19 @@ export function connectPanePty( deps.setCacheTimerStartedAt(cacheKey, null) } + // Why: remote repos route PTY spawn through the SSH provider. Resolve the + // repo's connectionId from the store so the transport passes it to pty:spawn. + const state = useAppStore.getState() + const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat() + const worktree = allWorktrees.find((w) => w.id === deps.worktreeId) + const repo = worktree ? state.repos?.find((r) => r.id === worktree.repoId) : null + const connectionId = repo?.connectionId ?? null + const transport = createIpcPtyTransport({ cwd: deps.cwd, env: paneStartup?.env, command: paneStartup?.command, + connectionId, onPtyExit: onExit, onTitleChange, onPtySpawn, diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts new file mode 100644 index 00000000..e14b72e0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -0,0 +1,163 @@ +/** + * Singleton PTY event dispatcher and eager buffer helpers. + * + * Why extracted: keeps pty-transport.ts under the 300-line limit while + * co-locating the global handler maps that both the transport factory + * and the eager-buffer reconnection logic share. + */ +import type { OpenCodeStatusEvent } from '../../../../shared/types' + +// ── Singleton PTY event dispatcher ─────────────────────────────────── +// One global IPC listener per channel, routes events to transports by +// PTY ID. Eliminates the N-listener problem that triggers +// MaxListenersExceededWarning with many panes/tabs. + +export const ptyDataHandlers = new Map void>() +export const ptyExitHandlers = new Map void>() +export const openCodeStatusHandlers = new Map void>() +let ptyDispatcherAttached = false + +export function ensurePtyDispatcher(): void { + if (ptyDispatcherAttached) { + return + } + ptyDispatcherAttached = true + window.api.pty.onData((payload) => { + ptyDataHandlers.get(payload.id)?.(payload.data) + }) + window.api.pty.onExit((payload) => { + ptyExitHandlers.get(payload.id)?.(payload.code) + }) + window.api.pty.onOpenCodeStatus((payload) => { + openCodeStatusHandlers.get(payload.ptyId)?.(payload) + }) +} + +// ─── Eager PTY buffer for reconnection on restart ──────────────────── +// Why: On startup, PTYs are spawned before TerminalPane mounts. Shell output +// (prompt, MOTD) arrives via pty:data before xterm exists. These helpers buffer +// that output so transport.attach() can replay it when the pane finally mounts. + +export type EagerPtyHandle = { flush: () => string; dispose: () => void } +const eagerPtyHandles = new Map() + +export function getEagerPtyBufferHandle(ptyId: string): EagerPtyHandle | undefined { + return eagerPtyHandles.get(ptyId) +} + +// Why: 512 KB matches the scrollback buffer cap used by TerminalPane's +// serialization. Prevents unbounded memory growth if a restored shell +// runs a long-lived command (e.g. tail -f) in a worktree the user never opens. +const EAGER_BUFFER_MAX_BYTES = 512 * 1024 + +export function registerEagerPtyBuffer( + ptyId: string, + onExit: (ptyId: string, code: number) => void +): EagerPtyHandle { + ensurePtyDispatcher() + + const buffer: string[] = [] + let bufferBytes = 0 + + const dataHandler = (data: string): void => { + buffer.push(data) + bufferBytes += data.length + // Trim from the front when the buffer exceeds the cap, keeping the + // most recent output which contains the shell prompt. + while (bufferBytes > EAGER_BUFFER_MAX_BYTES && buffer.length > 1) { + bufferBytes -= buffer.shift()!.length + } + } + const exitHandler = (code: number): void => { + // Shell died before TerminalPane attached — clean up and notify the store + // so the tab's ptyId is cleared and connectPanePty falls through to connect(). + ptyDataHandlers.delete(ptyId) + ptyExitHandlers.delete(ptyId) + eagerPtyHandles.delete(ptyId) + onExit(ptyId, code) + } + + ptyDataHandlers.set(ptyId, dataHandler) + ptyExitHandlers.set(ptyId, exitHandler) + + const handle: EagerPtyHandle = { + flush() { + const data = buffer.join('') + buffer.length = 0 + return data + }, + dispose() { + // Only remove if the current handler is still the temp one (compare by + // reference). After attach() replaces the handler this becomes a no-op. + if (ptyDataHandlers.get(ptyId) === dataHandler) { + ptyDataHandlers.delete(ptyId) + } + if (ptyExitHandlers.get(ptyId) === exitHandler) { + ptyExitHandlers.delete(ptyId) + } + eagerPtyHandles.delete(ptyId) + } + } + + eagerPtyHandles.set(ptyId, handle) + return handle +} + +// ── PtyTransport interface ─────────────────────────────────────────── +// Why: lives here so pty-transport.ts stays under the 300-line limit. + +export type PtyTransport = { + connect: (options: { + url: string + cols?: number + rows?: number + callbacks: { + onConnect?: () => void + onDisconnect?: () => void + onData?: (data: string) => void + onStatus?: (shell: string) => void + onError?: (message: string, errors?: string[]) => void + onExit?: (code: number) => void + } + }) => void | Promise + /** Attach to an existing PTY that was eagerly spawned during startup. + * Skips pty:spawn — registers handlers and replays buffered data instead. */ + attach: (options: { + existingPtyId: string + cols?: number + rows?: number + callbacks: { + onConnect?: () => void + onDisconnect?: () => void + onData?: (data: string) => void + onStatus?: (shell: string) => void + onError?: (message: string, errors?: string[]) => void + onExit?: (code: number) => void + } + }) => void + disconnect: () => void + sendInput: (data: string) => boolean + resize: ( + cols: number, + rows: number, + meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number } + ) => boolean + isConnected: () => boolean + getPtyId: () => string | null + preserve?: () => void + destroy?: () => void | Promise +} + +export type IpcPtyTransportOptions = { + cwd?: string + env?: Record + command?: string + connectionId?: string | null + onPtyExit?: (ptyId: string) => void + onTitleChange?: (title: string, rawTitle: string) => void + onPtySpawn?: (ptyId: string) => void + onBell?: () => void + onAgentBecameIdle?: (title: string) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 209dbdb7..774d33ba 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines */ import { detectAgentStatusFromTitle, clearWorkingIndicators, @@ -7,164 +6,31 @@ import { extractLastOscTitle } from '../../../../shared/agent-detection' import type { OpenCodeStatusEvent } from '../../../../shared/types' +import { + ptyDataHandlers, + ptyExitHandlers, + openCodeStatusHandlers, + ensurePtyDispatcher, + getEagerPtyBufferHandle +} from './pty-dispatcher' +import type { PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher' +import { createBellDetector } from './bell-detector' -export type PtyTransport = { - connect: (options: { - url: string - cols?: number - rows?: number - callbacks: { - onConnect?: () => void - onDisconnect?: () => void - onData?: (data: string) => void - onStatus?: (shell: string) => void - onError?: (message: string, errors?: string[]) => void - onExit?: (code: number) => void - } - }) => void | Promise - /** Attach to an existing PTY that was eagerly spawned during startup. - * Skips pty:spawn — registers handlers and replays buffered data instead. */ - attach: (options: { - existingPtyId: string - cols?: number - rows?: number - callbacks: { - onConnect?: () => void - onDisconnect?: () => void - onData?: (data: string) => void - onStatus?: (shell: string) => void - onError?: (message: string, errors?: string[]) => void - onExit?: (code: number) => void - } - }) => void - disconnect: () => void - sendInput: (data: string) => boolean - resize: ( - cols: number, - rows: number, - meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number } - ) => boolean - isConnected: () => boolean - getPtyId: () => string | null - destroy?: () => void | Promise -} - -// Singleton PTY event dispatcher — one global IPC listener per channel, -// routes events to transports by PTY ID. Eliminates the N-listener problem -// that triggers MaxListenersExceededWarning with many panes/tabs. -const ptyDataHandlers = new Map void>() -const ptyExitHandlers = new Map void>() -const openCodeStatusHandlers = new Map void>() -let ptyDispatcherAttached = false - -export function ensurePtyDispatcher(): void { - if (ptyDispatcherAttached) { - return - } - ptyDispatcherAttached = true - window.api.pty.onData((payload) => { - ptyDataHandlers.get(payload.id)?.(payload.data) - }) - window.api.pty.onExit((payload) => { - ptyExitHandlers.get(payload.id)?.(payload.code) - }) - window.api.pty.onOpenCodeStatus((payload) => { - openCodeStatusHandlers.get(payload.ptyId)?.(payload) - }) -} - -// ─── Eager PTY buffer for reconnection on restart ─────────────────── -// Why: On startup, PTYs are spawned before TerminalPane mounts. Shell output -// (prompt, MOTD) arrives via pty:data before xterm exists. These helpers buffer -// that output so transport.attach() can replay it when the pane finally mounts. - -type EagerPtyHandle = { flush: () => string; dispose: () => void } -const eagerPtyHandles = new Map() - -export function getEagerPtyBufferHandle(ptyId: string): EagerPtyHandle | undefined { - return eagerPtyHandles.get(ptyId) -} - -// Why: 512 KB matches the scrollback buffer cap used by TerminalPane's -// serialization. Prevents unbounded memory growth if a restored shell -// runs a long-lived command (e.g. tail -f) in a worktree the user never opens. -const EAGER_BUFFER_MAX_BYTES = 512 * 1024 - -export function registerEagerPtyBuffer( - ptyId: string, - onExit: (ptyId: string, code: number) => void -): EagerPtyHandle { - ensurePtyDispatcher() - - const buffer: string[] = [] - let bufferBytes = 0 - - const dataHandler = (data: string): void => { - buffer.push(data) - bufferBytes += data.length - // Trim from the front when the buffer exceeds the cap, keeping the - // most recent output which contains the shell prompt. - while (bufferBytes > EAGER_BUFFER_MAX_BYTES && buffer.length > 1) { - bufferBytes -= buffer.shift()!.length - } - } - const exitHandler = (code: number): void => { - // Shell died before TerminalPane attached — clean up and notify the store - // so the tab's ptyId is cleared and connectPanePty falls through to connect(). - ptyDataHandlers.delete(ptyId) - ptyExitHandlers.delete(ptyId) - eagerPtyHandles.delete(ptyId) - onExit(ptyId, code) - } - - ptyDataHandlers.set(ptyId, dataHandler) - ptyExitHandlers.set(ptyId, exitHandler) - - const handle: EagerPtyHandle = { - flush() { - const data = buffer.join('') - buffer.length = 0 - return data - }, - dispose() { - // Only remove if the current handler is still the temp one (compare by - // reference). After attach() replaces the handler this becomes a no-op. - if (ptyDataHandlers.get(ptyId) === dataHandler) { - ptyDataHandlers.delete(ptyId) - } - if (ptyExitHandlers.get(ptyId) === exitHandler) { - ptyExitHandlers.delete(ptyId) - } - eagerPtyHandles.delete(ptyId) - } - } - - eagerPtyHandles.set(ptyId, handle) - return handle -} - -// extractLastOscTitle is now imported from shared/agent-detection.ts -// Re-export for consumers that import it from this module. +// Re-export public API so existing consumers keep working. +export { + ensurePtyDispatcher, + getEagerPtyBufferHandle, + registerEagerPtyBuffer +} from './pty-dispatcher' +export type { EagerPtyHandle, PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher' export { extractLastOscTitle } from '../../../../shared/agent-detection' -export type IpcPtyTransportOptions = { - cwd?: string - env?: Record - command?: string - onPtyExit?: (ptyId: string) => void - onTitleChange?: (title: string, rawTitle: string) => void - onPtySpawn?: (ptyId: string) => void - onBell?: () => void - onAgentBecameIdle?: (title: string) => void - onAgentBecameWorking?: () => void - onAgentExited?: () => void -} - export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTransport { const { cwd, env, command, + connectionId, onPtyExit, onTitleChange, onPtySpawn, @@ -176,9 +42,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra let connected = false let destroyed = false let ptyId: string | null = null - let pendingEscape = false - let inOsc = false - let pendingOscEscape = false + const chunkContainsBell = createBellDetector() let suppressAttentionEvents = false let lastEmittedTitle: string | null = null let lastObservedTerminalTitle: string | null = null @@ -197,17 +61,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ) : null - // How long data must flow without a title update before we consider - // the last agent-working title stale and clear it (ms). - const STALE_TITLE_TIMEOUT = 3000 - let storedCallbacks: { - onConnect?: () => void - onDisconnect?: () => void - onData?: (data: string) => void - onStatus?: (shell: string) => void - onError?: (message: string, errors?: string[]) => void - onExit?: (code: number) => void - } = {} + const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared + let storedCallbacks: Parameters[0]['callbacks'] = {} function unregisterPtyHandlers(id: string): void { ptyDataHandlers.delete(id) @@ -246,12 +101,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra function applyObservedTerminalTitle(title: string): void { lastObservedTerminalTitle = title - - // Why: OpenCode can keep emitting plain titles like "OpenCode" while the - // session is still busy. If we let those raw titles overwrite the - // hook-derived state, the working spinner flashes briefly and disappears. - // While OpenCode has an explicit non-idle status, that status is the - // source of truth and the observed title is only used as context text. + // Why: while OpenCode has an explicit non-idle status, that status is the + // source of truth — the observed title is only used as context text. if (openCodeStatus && openCodeStatus !== 'idle') { applyOpenCodeStatus({ ptyId: ptyId ?? '', status: openCodeStatus }) return @@ -262,6 +113,56 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra agentTracker?.handleTitle(title) } + // Why: shared by connect() and attach() to avoid duplicating title/bell/exit logic. + function registerPtyDataHandler(id: string): void { + ptyDataHandlers.set(id, (data) => { + storedCallbacks.onData?.(data) + if (onTitleChange) { + const title = extractLastOscTitle(data) + if (title !== null) { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } + 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) + } + } + if (onBell && chunkContainsBell(data) && !suppressAttentionEvents) { + onBell() + } + }) + } + + function registerPtyExitHandler(id: string): void { + ptyExitHandlers.set(id, (code) => { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } + openCodeStatus = null + connected = false + ptyId = null + unregisterPtyHandlers(id) + storedCallbacks.onExit?.(code) + storedCallbacks.onDisconnect?.() + onPtyExit?.(id) + }) + openCodeStatusHandlers.set(id, applyOpenCodeStatus) + } + return { async connect(options) { storedCallbacks = options.callbacks @@ -277,7 +178,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra rows: options.rows ?? 24, cwd, env, - command + command, + ...(connectionId ? { connectionId } : {}) }) // If destroyed while spawn was in flight, kill the new pty and bail @@ -290,60 +192,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra connected = true onPtySpawn?.(result.id) - ptyDataHandlers.set(result.id, (data) => { - storedCallbacks.onData?.(data) - if (onTitleChange) { - const title = extractLastOscTitle(data) - if (title !== null) { - // Got a fresh title — clear any pending stale-title timer - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } - applyObservedTerminalTitle(title) - } else if ( - lastEmittedTitle && - detectAgentStatusFromTitle(lastEmittedTitle) === 'working' - ) { - // Data flowing but no title update — the agent may have exited. - // Start/restart a debounce timer to clear the stale working title. - 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) - } - } - if (onBell && chunkContainsBell(data) && !suppressAttentionEvents) { - onBell() - } - }) - - const spawnedId = result.id - ptyExitHandlers.set(spawnedId, (code) => { - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } - openCodeStatus = null - connected = false - ptyId = null - unregisterPtyHandlers(spawnedId) - storedCallbacks.onExit?.(code) - storedCallbacks.onDisconnect?.() - onPtyExit?.(spawnedId) - }) - openCodeStatusHandlers.set(spawnedId, applyOpenCodeStatus) + registerPtyDataHandler(result.id) + registerPtyExitHandler(result.id) storedCallbacks.onConnect?.() storedCallbacks.onStatus?.('shell') @@ -364,65 +214,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra const id = options.existingPtyId ptyId = id connected = true - // Why: intentionally skip onPtySpawn here. onPtySpawn feeds into - // updateTabPtyId → bumpWorktreeActivity, which would reset the - // worktree's lastActivityAt to now, destroying the recency sort order - // that reconnectPersistedTerminals explicitly preserved. + // Why: skip onPtySpawn — it would reset lastActivityAt and destroy the + // recency sort order that reconnectPersistedTerminals preserved. + registerPtyDataHandler(id) + registerPtyExitHandler(id) - // Replace the temporary eager-buffer handlers with real xterm handlers. - ptyDataHandlers.set(id, (data) => { - storedCallbacks.onData?.(data) - if (onTitleChange) { - const title = extractLastOscTitle(data) - if (title !== null) { - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } - 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) - } - } - if (onBell && chunkContainsBell(data) && !suppressAttentionEvents) { - onBell() - } - }) - - ptyExitHandlers.set(id, (code) => { - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } - openCodeStatus = null - connected = false - ptyId = null - unregisterPtyHandlers(id) - storedCallbacks.onExit?.(code) - storedCallbacks.onDisconnect?.() - onPtyExit?.(id) - }) - openCodeStatusHandlers.set(id, applyOpenCodeStatus) - - // Replay any data buffered between eager spawn and now. Route through - // the real data handler (not storedCallbacks.onData directly) so that - // OSC title extraction, agent status tracking, and bell detection all - // process the buffered output — otherwise restored tabs keep a default - // title until later output arrives. + // Why: replay buffered data through the real handler so title/bell/agent + // tracking processes the output — otherwise restored tabs keep a default title. const bufferHandle = getEagerPtyBufferHandle(id) if (bufferHandle) { const buffered = bufferHandle.flush() @@ -496,51 +294,4 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra this.disconnect() } } - - function chunkContainsBell(data: string): boolean { - for (let i = 0; i < data.length; i += 1) { - const char = data[i] - - if (inOsc) { - if (pendingOscEscape) { - pendingOscEscape = char === '\x1b' - if (char === '\\') { - inOsc = false - pendingOscEscape = false - } - continue - } - - if (char === '\x07') { - inOsc = false - continue - } - - pendingOscEscape = char === '\x1b' - continue - } - - if (pendingEscape) { - pendingEscape = false - if (char === ']') { - inOsc = true - pendingOscEscape = false - } else if (char === '\x1b') { - pendingEscape = true - } - continue - } - - if (char === '\x1b') { - pendingEscape = true - continue - } - - if (char === '\x07') { - return true - } - } - - return false - } } 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 2b764f7a..319e7001 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -7,6 +7,7 @@ import { toWorktreeRelativePath } from '@/lib/terminal-links' import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' import type { PaneManager } from '@/lib/pane-manager/pane-manager' export type LinkHandlerDeps = { @@ -46,8 +47,12 @@ export function openDetectedFilePath( void (async () => { let statResult try { - await window.api.fs.authorizeExternalPath({ targetPath: filePath }) - statResult = await window.api.fs.stat({ filePath }) + const connectionId = getConnectionId(deps.worktreeId ?? null) ?? undefined + // Why: remote paths don't need local auth — the relay is the security boundary. + if (!connectionId) { + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + } + statResult = await window.api.fs.stat({ filePath, connectionId }) } catch { return } diff --git a/src/renderer/src/hooks/ipc-tab-switch.ts b/src/renderer/src/hooks/ipc-tab-switch.ts new file mode 100644 index 00000000..5269afde --- /dev/null +++ b/src/renderer/src/hooks/ipc-tab-switch.ts @@ -0,0 +1,59 @@ +import { useAppStore } from '../store' +import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' + +/** + * Handle Cmd/Ctrl+Tab direction switching across terminal, editor, and browser tabs. + * Extracted from useIpcEvents to keep file size under the max-lines lint threshold. + */ +export function handleSwitchTab(direction: number): void { + const store = useAppStore.getState() + const worktreeId = store.activeWorktreeId + if (!worktreeId) { + return + } + const terminalTabs = store.tabsByWorktree[worktreeId] ?? [] + const editorFiles = store.openFiles.filter((f) => f.worktreeId === worktreeId) + const browserTabs = store.browserTabsByWorktree[worktreeId] ?? [] + const terminalIds = terminalTabs.map((t) => t.id) + const editorIds = editorFiles.map((f) => f.id) + const browserIds = browserTabs.map((t) => t.id) + const reconciledOrder = reconcileTabOrder( + store.tabBarOrderByWorktree[worktreeId], + terminalIds, + editorIds, + browserIds + ) + const terminalIdSet = new Set(terminalIds) + const editorIdSet = new Set(editorIds) + const browserIdSet = new Set(browserIds) + const allTabIds = reconciledOrder.map((id) => ({ + type: terminalIdSet.has(id) + ? ('terminal' as const) + : editorIdSet.has(id) + ? ('editor' as const) + : browserIdSet.has(id) + ? ('browser' as const) + : (null as never), + id + })) + if (allTabIds.length > 1) { + const currentId = + store.activeTabType === 'editor' + ? store.activeFileId + : store.activeTabType === 'browser' + ? store.activeBrowserTabId + : store.activeTabId + const idx = allTabIds.findIndex((t) => t.id === currentId) + const next = allTabIds[(idx + direction + allTabIds.length) % allTabIds.length] + if (next.type === 'terminal') { + store.setActiveTab(next.id) + store.setActiveTabType('terminal') + } else if (next.type === 'browser') { + store.setActiveBrowserTab(next.id) + store.setActiveTabType('browser') + } else { + store.setActiveFile(next.id) + store.setActiveTabType('editor') + } + } +} diff --git a/src/renderer/src/hooks/resolve-zoom-target.ts b/src/renderer/src/hooks/resolve-zoom-target.ts new file mode 100644 index 00000000..416f979a --- /dev/null +++ b/src/renderer/src/hooks/resolve-zoom-target.ts @@ -0,0 +1,49 @@ +/** + * Determine which zoom domain (terminal, editor, or UI) should be adjusted + * based on current view, tab type, and focused element. + */ +export function resolveZoomTarget(args: { + activeView: 'terminal' | 'settings' + activeTabType: 'terminal' | 'editor' | 'browser' + activeElement: unknown +}): 'terminal' | 'editor' | 'ui' { + const { activeView, activeTabType, activeElement } = args + const terminalInputFocused = + typeof activeElement === 'object' && + activeElement !== null && + 'classList' in activeElement && + typeof (activeElement as { classList?: { contains?: unknown } }).classList?.contains === + 'function' && + (activeElement as { classList: { contains: (token: string) => boolean } }).classList.contains( + 'xterm-helper-textarea' + ) + const editorFocused = + typeof activeElement === 'object' && + activeElement !== null && + 'closest' in activeElement && + typeof (activeElement as { closest?: unknown }).closest === 'function' && + Boolean( + ( + activeElement as { + closest: (selector: string) => Element | null + } + ).closest( + '.monaco-editor, .diff-editor, .markdown-preview, .rich-markdown-editor, .rich-markdown-editor-shell' + ) + ) + + if (activeView !== 'terminal') { + return 'ui' + } + if (activeTabType === 'editor' || editorFocused) { + return 'editor' + } + // Why: terminal tabs should keep using per-pane terminal font zoom even when + // focus leaves the xterm textarea (e.g. clicking tab bar/sidebar controls). + // Falling back to UI zoom here would resize the whole app for a terminal-only + // action and break parity with terminal zoom behavior. + if (activeTabType === 'terminal' || terminalInputFocused) { + return 'terminal' + } + return 'ui' +} diff --git a/src/renderer/src/hooks/useGlobalFileDrop.ts b/src/renderer/src/hooks/useGlobalFileDrop.ts index 5b0d05df..6641aa84 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { detectLanguage } from '@/lib/language-detect' import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' export function useGlobalFileDrop(): void { useEffect(() => { @@ -21,8 +22,12 @@ export function useGlobalFileDrop(): void { void (async () => { try { - await window.api.fs.authorizeExternalPath({ targetPath: filePath }) - const stat = await window.api.fs.stat({ filePath }) + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + // Why: remote paths don't need local auth — the relay is the security boundary. + if (!connectionId) { + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + } + const stat = await window.api.fs.stat({ filePath, connectionId }) if (stat.isDirectory) { return } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 14fd8e32..ac340fc2 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -100,6 +100,8 @@ describe('useIpcEvents updater integration', () => { editorFontZoomLevel: 0, setEditorFontZoomLevel: vi.fn(), setRateLimitsFromPush: vi.fn(), + setSshConnectionState: vi.fn(), + setSshTargetLabels: vi.fn(), settings: { terminalFontSize: 13 } }) } @@ -163,6 +165,11 @@ describe('useIpcEvents updater integration', () => { rateLimits: { get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), onUpdate: () => () => {} + }, + ssh: { + listTargets: () => Promise.resolve([]), + getState: () => Promise.resolve(null), + onStateChanged: () => () => {} } } }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index f142867c..de3c8442 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -10,58 +10,16 @@ import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom' import type { UpdateStatus } from '../../../shared/types' import type { RateLimitState } from '../../../shared/rate-limit-types' +import type { SshConnectionState } from '../../../shared/ssh-types' import { zoomLevelToPercent, ZOOM_MIN, ZOOM_MAX } from '@/components/settings/SettingsConstants' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' -import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' +import { resolveZoomTarget } from './resolve-zoom-target' +import { handleSwitchTab } from './ipc-tab-switch' + +export { resolveZoomTarget } from './resolve-zoom-target' const ZOOM_STEP = 0.5 -export function resolveZoomTarget(args: { - activeView: 'terminal' | 'settings' - activeTabType: 'terminal' | 'editor' | 'browser' - activeElement: unknown -}): 'terminal' | 'editor' | 'ui' { - const { activeView, activeTabType, activeElement } = args - const terminalInputFocused = - typeof activeElement === 'object' && - activeElement !== null && - 'classList' in activeElement && - typeof (activeElement as { classList?: { contains?: unknown } }).classList?.contains === - 'function' && - (activeElement as { classList: { contains: (token: string) => boolean } }).classList.contains( - 'xterm-helper-textarea' - ) - const editorFocused = - typeof activeElement === 'object' && - activeElement !== null && - 'closest' in activeElement && - typeof (activeElement as { closest?: unknown }).closest === 'function' && - Boolean( - ( - activeElement as { - closest: (selector: string) => Element | null - } - ).closest( - '.monaco-editor, .diff-editor, .markdown-preview, .rich-markdown-editor, .rich-markdown-editor-shell' - ) - ) - - if (activeView !== 'terminal') { - return 'ui' - } - if (activeTabType === 'editor' || editorFocused) { - return 'editor' - } - // Why: terminal tabs should keep using per-pane terminal font zoom even when - // focus leaves the xterm textarea (e.g. clicking tab bar/sidebar controls). - // Falling back to UI zoom here would resize the whole app for a terminal-only - // action and break parity with terminal zoom behavior. - if (activeTabType === 'terminal' || terminalInputFocused) { - return 'terminal' - } - return 'ui' -} - export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] @@ -254,60 +212,7 @@ export function useIpcEvents(): void { }) ) - unsubs.push( - window.api.ui.onSwitchTab((direction) => { - const store = useAppStore.getState() - const worktreeId = store.activeWorktreeId - if (!worktreeId) { - return - } - const terminalTabs = store.tabsByWorktree[worktreeId] ?? [] - const editorFiles = store.openFiles.filter((f) => f.worktreeId === worktreeId) - const browserTabs = store.browserTabsByWorktree[worktreeId] ?? [] - const terminalIds = terminalTabs.map((t) => t.id) - const editorIds = editorFiles.map((f) => f.id) - const browserIds = browserTabs.map((t) => t.id) - const reconciledOrder = reconcileTabOrder( - store.tabBarOrderByWorktree[worktreeId], - terminalIds, - editorIds, - browserIds - ) - const terminalIdSet = new Set(terminalIds) - const editorIdSet = new Set(editorIds) - const browserIdSet = new Set(browserIds) - const allTabIds = reconciledOrder.map((id) => ({ - type: terminalIdSet.has(id) - ? ('terminal' as const) - : editorIdSet.has(id) - ? ('editor' as const) - : browserIdSet.has(id) - ? ('browser' as const) - : (null as never), - id - })) - if (allTabIds.length > 1) { - const currentId = - store.activeTabType === 'editor' - ? store.activeFileId - : store.activeTabType === 'browser' - ? store.activeBrowserTabId - : store.activeTabId - const idx = allTabIds.findIndex((t) => t.id === currentId) - const next = allTabIds[(idx + direction + allTabIds.length) % allTabIds.length] - if (next.type === 'terminal') { - store.setActiveTab(next.id) - store.setActiveTabType('terminal') - } else if (next.type === 'browser') { - store.setActiveBrowserTab(next.id) - store.setActiveTabType('browser') - } else { - store.setActiveFile(next.id) - store.setActiveTabType('editor') - } - } - }) - ) + unsubs.push(window.api.ui.onSwitchTab(handleSwitchTab)) // Hydrate initial rate limit state then subscribe to push updates window.api.rateLimits.get().then((state) => { @@ -320,6 +225,40 @@ export function useIpcEvents(): void { }) ) + // Track SSH connection state changes so the renderer can show + // disconnected indicators on remote worktrees. + // Why: hydrate initial state for all known targets so worktree cards + // reflect the correct connected/disconnected state on app launch. + void (async () => { + try { + const targets = (await window.api.ssh.listTargets()) as { + id: string + label: string + }[] + // Why: populate target labels map so WorktreeCard (and other components) + // can look up display labels without issuing per-card IPC calls. + const labels = new Map() + for (const target of targets) { + labels.set(target.id, target.label) + const state = await window.api.ssh.getState({ targetId: target.id }) + if (state) { + useAppStore.getState().setSshConnectionState(target.id, state as SshConnectionState) + } + } + useAppStore.getState().setSshTargetLabels(labels) + } catch { + // SSH may not be configured + } + })() + + unsubs.push( + window.api.ssh.onStateChanged((data: { targetId: string; state: unknown }) => { + useAppStore + .getState() + .setSshConnectionState(data.targetId, data.state as SshConnectionState) + }) + ) + // Zoom handling for menu accelerators and keyboard fallback paths. unsubs.push( window.api.ui.onTerminalZoom((direction) => { diff --git a/src/renderer/src/lib/connection-context.ts b/src/renderer/src/lib/connection-context.ts new file mode 100644 index 00000000..72f04db3 --- /dev/null +++ b/src/renderer/src/lib/connection-context.ts @@ -0,0 +1,20 @@ +import { useAppStore } from '@/store' + +/** + * Resolve the SSH connectionId for a worktree. Returns null for local repos, + * the target ID string for remote repos, or undefined if the worktree/repo + * cannot be found (e.g., store not yet hydrated). + */ +export function getConnectionId(worktreeId: string | null): string | null | undefined { + if (!worktreeId) { + return null + } + const state = useAppStore.getState() + const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat() + const worktree = allWorktrees.find((w) => w.id === worktreeId) + if (!worktree) { + return undefined + } + const repo = state.repos?.find((r) => r.id === worktree.repoId) + return repo?.connectionId ?? null +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 72a8dff2..37b1be8b 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -13,6 +13,7 @@ import { createClaudeUsageSlice } from './slices/claude-usage' import { createCodexUsageSlice } from './slices/codex-usage' import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' +import { createSshSlice } from './slices/ssh' export const useAppStore = create()((...a) => ({ ...createRepoSlice(...a), @@ -27,7 +28,8 @@ export const useAppStore = create()((...a) => ({ ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), ...createBrowserSlice(...a), - ...createRateLimitSlice(...a) + ...createRateLimitSlice(...a), + ...createSshSlice(...a) })) export type { AppState } from './types' diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts new file mode 100644 index 00000000..e64f3819 --- /dev/null +++ b/src/renderer/src/store/slices/ssh.ts @@ -0,0 +1,35 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { SshConnectionState, SshConnectionStatus } from '../../../../shared/ssh-types' + +export type SshSlice = { + sshConnectionStates: Map + /** Maps target IDs to their user-facing labels. Populated during hydration + * so components can look up labels without per-component IPC calls. */ + sshTargetLabels: Map + setSshConnectionState: (targetId: string, state: SshConnectionState) => void + setSshTargetLabels: (labels: Map) => void + getSshConnectionStatus: (connectionId: string | null | undefined) => SshConnectionStatus | null +} + +export const createSshSlice: StateCreator = (set, get) => ({ + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + + setSshConnectionState: (targetId, state) => + set(() => { + const next = new Map(get().sshConnectionStates) + next.set(targetId, state) + return { sshConnectionStates: next } + }), + + setSshTargetLabels: (labels) => set({ sshTargetLabels: labels }), + + getSshConnectionStatus: (connectionId) => { + if (!connectionId) { + return null + } + const state = get().sshConnectionStates.get(connectionId) + return state?.status ?? 'disconnected' + } +}) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index df08ed7d..8bcacd05 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -97,6 +97,7 @@ import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' +import { createSshSlice } from './ssh' function createTestStore() { return create()((...a) => ({ @@ -112,7 +113,8 @@ function createTestStore() { ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), ...createBrowserSlice(...a), - ...createRateLimitSlice(...a) + ...createRateLimitSlice(...a), + ...createSshSlice(...a) })) } diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index e27d2115..c43519a6 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -21,6 +21,7 @@ import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' +import { createSshSlice } from './ssh' export const TEST_REPO = { id: 'repo1', @@ -44,7 +45,8 @@ export function createTestStore() { ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), ...createBrowserSlice(...a), - ...createRateLimitSlice(...a) + ...createRateLimitSlice(...a), + ...createSshSlice(...a) })) } diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index a8a59330..95f61271 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -92,6 +92,7 @@ import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' +import { createSshSlice } from './ssh' const WT = 'repo1::/tmp/feature' @@ -109,7 +110,8 @@ function createTestStore() { ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), ...createBrowserSlice(...a), - ...createRateLimitSlice(...a) + ...createRateLimitSlice(...a), + ...createSshSlice(...a) })) } diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index b507eca0..26eacdc0 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -11,6 +11,7 @@ import type { ClaudeUsageSlice } from './slices/claude-usage' import type { CodexUsageSlice } from './slices/codex-usage' import type { BrowserSlice } from './slices/browser' import type { RateLimitSlice } from './slices/rate-limits' +import type { SshSlice } from './slices/ssh' export type AppState = RepoSlice & WorktreeSlice & @@ -24,4 +25,5 @@ export type AppState = RepoSlice & ClaudeUsageSlice & CodexUsageSlice & BrowserSlice & - RateLimitSlice + RateLimitSlice & + SshSlice diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4b42f671..f523e15a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -135,7 +135,8 @@ export function getDefaultPersistedState(homedir: string): PersistedState { settings: getDefaultSettings(homedir), ui: getDefaultUIState(), githubCache: { pr: {}, issue: {} }, - workspaceSession: getDefaultWorkspaceSession() + workspaceSession: getDefaultWorkspaceSession(), + sshTargets: [] } } diff --git a/src/shared/ssh-types.test.ts b/src/shared/ssh-types.test.ts new file mode 100644 index 00000000..8b03577b --- /dev/null +++ b/src/shared/ssh-types.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import type { SshTarget, SshConnectionState, SshConnectionStatus } from './ssh-types' + +describe('SSH types', () => { + it('SshTarget has required fields', () => { + const target: SshTarget = { + id: 'target-1', + label: 'My Server', + host: 'myserver.com', + port: 22, + username: 'deploy' + } + expect(target.id).toBe('target-1') + expect(target.host).toBe('myserver.com') + }) + + it('SshConnectionStatus covers all expected states', () => { + const statuses: SshConnectionStatus[] = [ + 'disconnected', + 'connecting', + 'host-key-verification', + 'auth-challenge', + 'auth-failed', + 'deploying-relay', + 'connected', + 'reconnecting', + 'reconnection-failed', + 'error' + ] + expect(statuses).toHaveLength(10) + }) + + it('SshConnectionState composes correctly', () => { + const state: SshConnectionState = { + targetId: 'target-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + expect(state.status).toBe('connected') + expect(state.error).toBeNull() + }) + + it('Repo.connectionId is optional for backward compatibility', () => { + // Import the Repo type to verify connectionId is optional + const repo = { + id: 'repo-1', + path: '/path/to/repo', + displayName: 'My Repo', + badgeColor: '#ff0000', + addedAt: Date.now() + } + // Should compile without connectionId + expect(repo.id).toBe('repo-1') + expect('connectionId' in repo).toBe(false) + + // Should also work with connectionId + const remoteRepo = { + ...repo, + connectionId: 'target-1' + } + expect(remoteRepo.connectionId).toBe('target-1') + }) +}) diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts new file mode 100644 index 00000000..bcd0b380 --- /dev/null +++ b/src/shared/ssh-types.ts @@ -0,0 +1,35 @@ +// ─── SSH Connection Types ─────────────────────────────────────────── + +export type SshTarget = { + id: string + label: string + host: string + port: number + username: string + /** Path to private key file, if using key-based auth. */ + identityFile?: string + /** ProxyCommand from SSH config, if any. */ + proxyCommand?: string + /** Jump host (ProxyJump), if any. */ + jumpHost?: string +} + +export type SshConnectionStatus = + | 'disconnected' + | 'connecting' + | 'host-key-verification' + | 'auth-challenge' + | 'auth-failed' + | 'deploying-relay' + | 'connected' + | 'reconnecting' + | 'reconnection-failed' + | 'error' + +export type SshConnectionState = { + targetId: string + status: SshConnectionStatus + error: string | null + /** Number of reconnection attempts since last disconnect. */ + reconnectAttempt: number +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 0aaf4268..a5bf07e5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import type { SshTarget } from './ssh-types' // ─── Repo ──────────────────────────────────────────────────────────── export type RepoKind = 'git' | 'folder' @@ -13,6 +14,8 @@ export type Repo = { gitUsername?: string worktreeBaseRef?: string hookSettings?: RepoHookSettings + /** SSH target ID for remote repos. null/undefined = local. */ + connectionId?: string | null } export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' @@ -557,6 +560,7 @@ export type PersistedState = { issue: Record } workspaceSession: WorkspaceSessionState + sshTargets: SshTarget[] } // ─── Filesystem ───────────────────────────────────────────── diff --git a/tsconfig.json b/tsconfig.json index db0d7c14..7b03f1a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,10 @@ { "files": [], - "references": [{ "path": "./config/tsconfig.node.json" }, { "path": "./config/tsconfig.web.json" }], + "references": [ + { "path": "./config/tsconfig.node.json" }, + { "path": "./config/tsconfig.web.json" }, + { "path": "./config/tsconfig.relay.json" } + ], "compilerOptions": { "baseUrl": ".", "paths": {