Skip to content

feat: Add SSH remote support (beta) - #590

Merged
Jinwoo-H merged 24 commits into
mainfrom
Jinwoo-H/ssh
Apr 14, 2026
Merged

Jinwoo-H merged 24 commits into
mainfrom
Jinwoo-H/ssh

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Apr 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Add the ability to connect to remote machines over SSH and interact with remote terminals, files, and git repos — the full Orca experience on a remote host.

Architecture: Lightweight Relay Daemon

A small Node.js relay is deployed to the remote (~/.orca-remote/relay-v{VERSION}) on first connect. All operations are multiplexed over a single SSH channel using JSON-RPC with 13-byte binary framing. The relay provides four capabilities: PTY management, filesystem operations, git operations, and file watching.

What's included

SSH Connection Management (Phase 1)

  • Connect/disconnect/reconnect lifecycle via ssh2 with exponential backoff
  • Auth support: SSH agent, key file, password, keyboard-interactive (2FA)
  • ~/.ssh/config import (host aliases, identity files, proxy commands, jump hosts)
  • Host key verification against ~/.ssh/known_hosts with UI prompt fallback
  • SSH target CRUD with persistence in orca-data.json
  • System SSH fallback for FIDO2 keys and ControlMaster
  • Auto-reconnection with relay re-deployment and PTY reattach

Remote Terminal (Phase 2)

  • Provider abstraction (IPtyProvider) routes PTY operations by connectionId
  • Remote PTY spawn/write/resize/kill through relay's pty.* JSON-RPC methods
  • Agent detection (OSC title parsing) works transparently on the data stream
  • PTY reconnection: relay keeps PTYs alive during 60s grace period, replays buffered output on reattach

Remote Filesystem (Phase 3)

  • IFilesystemProvider routes all fs operations (read/write/stat/search/watch) through relay
  • Remote file watcher via @parcel/watcher on the remote, streaming fs.changed events
  • Remote search via rg on the remote host
  • Symlink-aware path validation (validatePathResolved) prevents traversal attacks

Remote Git (Phase 4)

  • IGitProvider routes git status/diff/stage/unstage/discard/branch operations through relay
  • Git exec argument validation: allowlisted subcommands, denied flags, compound --flag=value syntax
  • Worktree list/add/remove operations via relay

Port Forwarding (Phase 6)

  • Local port forwarding via SSH channels
  • Port forward manager with add/remove/list per connection

Settings UI

  • SSH settings pane for managing targets (add, edit, remove, import from config)
  • Connection status indicators, test connection, deploy progress
  • Host key verification and auth challenge dialogs

Security Hardening

  • Relay-side workspace root ACL (RelayContext.registerRoot / validatePath)
  • Symlink-following validation for all mutating FS and search operations
  • Git exec allowlist with flag-injection prevention
  • Shell escaping for ProxyCommand interpolation
  • Path traversal checks in git diff and discard operations
  • macOS /tmp → /private/tmp symlink resolution in root registration

UX Polish

  • Globe + "SSH" badge on remote repos in sidebar and New Worktree dialog
  • Disconnected remote worktrees remain clickable (view terminal history, editor state)
  • Reconnection dialog shown as non-blocking overlay
  • Connection error states properly reset in UI (no stuck "connecting" spinner)

Test plan

  • Add SSH target via Settings > SSH, connect to a remote host
  • Verify relay deploys and terminal opens on remote
  • Run commands in remote terminal, verify agent detection works
  • Browse remote files in file explorer, search with Cmd+Shift+F
  • Git status/diff/stage/unstage on remote worktree
  • Disconnect SSH — verify UI shows error state, not stuck spinner
  • Click disconnected worktree — activates it, shows reconnect overlay
  • Dismiss overlay — terminal history and editor state still visible
  • Reconnect — verify PTY reattaches with buffered output
  • Port forward a remote port, verify local access
  • Import from ~/.ssh/config, verify hosts populate
  • pnpm test — all 1374 tests pass
  • pnpm lint and pnpm typecheck — clean

closes #326

@Jinwoo-H Jinwoo-H changed the title SSH: harden connection lifecycle, fix stuck UI states, improve remote UX feat: Add SSH remote support Apr 13, 2026
@Jinwoo-H Jinwoo-H changed the title feat: Add SSH remote support feat: Add SSH remote support (beta) Apr 13, 2026
@Jinwoo-H
Jinwoo-H marked this pull request as ready for review April 13, 2026 17:12
@Jinwoo-H
Jinwoo-H force-pushed the Jinwoo-H/ssh branch 3 times, most recently from c9c725f to 15cabbc Compare April 14, 2026 01:25
Jinwoo-H added 23 commits April 13, 2026 21:31
- SSH connection management (connect, disconnect, reconnect, port forwarding)
- Relay daemon protocol for remote PTY, filesystem, and git operations
- Provider abstraction layer dispatching local vs SSH-based operations
- Renderer SSH state tracking via new zustand SshSlice
- Disconnected worktree UI: dimmed cards, WifiOff icon, reconnect dialog
- Grey SSH badges in AddWorktreeDialog, disabled for disconnected repos
- Suppress test-connection state flicker via testingTargets guard
- Preserve worktree list on SSH disconnect instead of clearing
- SSH settings pane with Linux/macOS disclaimer
Security (P0):
- Relay fs/git handlers now validate all paths via context.validatePath(),
  closing arbitrary file read/write/delete on remote hosts
- Git relay restricts exec to allowlisted subcommands only
- Host key verification uses ssh2 callback-form hostVerifier with real
  SHA256 fingerprint and blocking user confirmation
- Shell injection prevented in ProxyCommand via shellEscape() helper
- Relay deploy uses SFTP write instead of shell printf for version file
- RelayContext rejects all paths before first root is registered

Correctness (P1):
- SshConnection: fixed double-reconnect race via client identity guard
- SshConnectionManager: concurrent connect guard via connectingTargets Set
- PTY handler: fixed revive ID collision (bumps nextId); SIGKILL fallback
  after 5s SIGTERM timeout
- FrameDecoder: skip oversized frames instead of corrupting buffer state
- Relay process exits after uncaughtException instead of running corrupted
- Converted pty-shell-utils from sync execFileSync to async
- Provider dispose() methods clean up notification subscriptions
- cleanupConnection clears PTY ownership before unregistering providers
- Relay deploy uses sort -V for proper semver ordering of nvm versions

UX/Performance (P2):
- SshPane reads connection states from store instead of per-render IPC
- WorktreeCard uses store-cached target labels instead of per-card IPC
- SshTargetCard buttons have aria-labels for accessibility
- SshDisconnectedDialog shows yellow dot for reconnecting state
- AddRepoDialog extracted useRemoteRepo hook to stay under 400-line limit
- Added connectionId routing to git:remoteFileUrl, repo git helpers

Tests:
- Relay tests register workspace roots in beforeEach (security fix compat)
- Subprocess tests send session.registerRoot before fs/git operations
- SSH IPC test mocks updated for new provider exports
…ce limits

- Add validatePathResolved() to RelayContext for symlink-aware path checks
- Use validatePathResolved() for fs.readFile, writeFile, deletePath
- Add validatePath to fs.watch + cap at 20 concurrent watchers
- Remove fetch/worktree from git exec allowlist (destructive subcommands)
- Add config read-only and branch destructive flag validation
- Add PTY spawn limit (50) and signal allowlist for sendSignal
- Clear killTimers in PtyHandler.dispose() to prevent SIGKILL to recycled PIDs
- Reject rel === '.' in discard to prevent rm -rf of worktree root
- Main-side FrameDecoder: skip oversized frames instead of throwing to
  prevent permanent stream corruption (mirrors relay behavior)
- Add concurrency guard to reestablishRelayStack with AbortController
  to prevent overlapping reconnections from leaking relay processes
- Dispose old PTY/FS providers before unregistering during reconnection
- Use crypto.randomUUID() for IPC response channels instead of Date.now()
- Validate targetDir in git.addWorktree to prevent directory creation
  outside authorized workspace roots
- Track and kill ProxyCommand child processes on SSH disconnect
- Cap stderr buffer in waitForSentinel to 64KB to prevent OOM
- Move ALLOWED_SIGNALS to module level in pty-handler
…ixes

Security:
- Shell-escape all remote paths in ssh-relay-deploy (prevent $HOME injection)
- Validate remote $HOME against safe character pattern
- Replace ProxyCommand PassThrough with Duplex (fix feedback loop)
- Add global denied git flags (--output, --exec-path, --work-tree, --git-dir)
- Block git config write flags (--add, --unset, --file, etc.) alongside read flags
- Reject global git flags before subcommand (blocks -c injection)
- Validate git addWorktree branch names don't start with "-"
- Upgrade fs-handler to validatePathResolved for readDir, stat, createFile,
  createDir, rename, copy (prevents symlink escape)
- Add symlink-aware validation for git discard on untracked files
- Restrict lsof to cwd file descriptor only (-d cwd)

Correctness:
- Fix type-2 git status parsing: path/oldPath were swapped for renames
- Restore dispose-before-kill in LocalPtyProvider (prevents SIGABRT crash)
- Fix jumpClient/proxyProcess leak on connection retry
- Always send fs.unwatch for specific rootPath on unsubscribe
- Resolve BrowserWindow lazily in wireUpSshPtyEvents (prevents stale ref)
- Clean up orphaned relay on abort in reestablishRelayStack
- Snapshot notification handlers array before iteration (prevents skip)
- Add timeout and window-close cleanup to IPC auth callbacks
- Unref keepalive timer so relay can exit naturally
- Clamp PTY resize cols/rows to 1-500, validate write data type

Tests:
- Update 4 pty.test.ts tests to async/await (handlers became async)
- Fix local-pty-provider to store and dispose onData/onExit disposables

Lint:
- Fix prefer-template errors in ssh-relay-deploy.ts
- Fix max-lines in pty-handler.ts (extract SerializedPtyEntry type)
- Fix no-useless-spread in ssh-channel-multiplexer.ts
…ote UX

Security: check known_hosts before prompting for host key verification,
extract git exec argument validation to separate module with full test
coverage, upgrade fs-handler search/listFiles to validatePathResolved,
add baseRef flag-injection guards in git branchCompare/branchDiff.

Correctness: fix connection timeout not resetting UI state (stuck on
"connecting"/"verifying host key"), fix PTY ownership leaks on SSH exit
and kill errors, fix port forward map mutation during iteration, fix
system SSH dual onExit handler race, fix auth callback window-closed
listener leak, guard testConnection from destroying active sessions,
add portForwardManager cleanup on reconnection.

UX: show Globe+SSH badge on remote repos in New Worktree dialog,
allow switching to disconnected remote worktrees to view terminal
history.
… limit

- Add cpu-features to pnpm.onlyBuiltDependencies for ssh2 native module
- Add build-essential to CI workflow for node-gyp compilation
- Extract SSH auth IPC callbacks into ssh-auth-helpers.ts (ssh.ts 388→297 lines)
…ll assumption

- subprocess.test.ts: tsx isn't a project dependency and unavailable in CI.
  Bundle the relay with esbuild in beforeAll and spawn with node instead.
- local-pty-provider.test.ts: hasChildProcesses test assumed default shell
  is zsh (macOS), but CI runs bash. Pin SHELL env var in test setup.
- Extract spawnRelay helper to subprocess-test-utils.ts (max-lines limit).
Instead of typing a remote path blindly, users can now click a Browse
button to navigate the remote filesystem visually — breadcrumb path bar,
directory listing with double-click navigation, and folder/file icons.

- New ssh:browseDir IPC: uses raw SSH exec (ls -1ap) to list directories,
  bypassing the relay's workspace ACL which isn't registered yet during
  the add-repo flow. Shell-escapes the path to prevent injection.
- RemoteFileBrowser component: breadcrumb nav, up/home buttons, scrollable
  directory listing, select/cancel actions.
- Wired into RemoteStep with a folder button next to the path input.
Single-quoting ~ prevents shell expansion, causing "no such directory".
Paths starting with ~ now use $HOME substitution instead.
Use isGitRepoAsync to check if the remote path is a git repo.
Non-git directories are added with kind: 'folder', disabling git
features — matching the local add-repo behavior. Also resolves to
the git root path when the user picks a subdirectory.
The relay's git.isGitRepo was blocked by the workspace ACL which
rejects all paths before any roots are registered. Since this check
runs during the add-repo flow (before registration), it always failed
and fell back to 'folder'. Remove the ACL check for this read-only
operation. Also skip the worktree setup step for folder repos, matching
the local add-repo behavior.
Fixes CI typecheck: getSshGitProvider returns IGitProvider but
isGitRepoAsync was only defined on the concrete SshGitProvider class.
The worktrees:list and worktrees:listAll handlers were calling
provider.listWorktrees for remote folder repos, which runs
git worktree list on a non-git directory and fails silently.
Check isFolderRepo first and return a synthetic root entry,
matching how local folder repos work. This makes remote folders
appear in the sidebar with a clickable entry.
Previously, non-git remote directories were silently added as folders
or failed to appear in the sidebar. Now the flow matches local repos:
repos:addRemote throws when the path isn't a git repo, the renderer
catches this and shows the NonGitFolderDialog, and the user confirms
to add with kind: 'folder'. Extended NonGitFolderDialog to handle
remote folders via repos.addRemote({ kind: 'folder' }).
Port the shell-ready startup command feature (PR #583) into our
provider-based PTY architecture. Extract shell-ready wrappers, OSC 133
scanner, and command writer into local-pty-shell-ready.ts. Restore
clean pty.ts as thin dispatch layer. Fix duplicate imports and unused
imports from rebase merge artifacts.
- Resolve import conflict in filesystem-watcher.ts (keep both WSL and SSH imports)
- Remove duplicate notifyWorktreesChanged local definition (already imported from worktree-remote)
- Add eslint-disable max-lines for filesystem-watcher.ts (WSL + SSH watcher code)
- Change Add Repo buttons to 3-column grid layout with tighter padding
- Show clear reconnecting state in SSH Disconnected dialog (spinner icon,
  updated title/description, animated loader replacing status dot)
- Keep SshDisconnectedDialog mounted to prevent close animation flash
  when connection status changes
@Jinwoo-H
Jinwoo-H merged commit cc66e12 into main Apr 14, 2026
1 check passed
@Jinwoo-H
Jinwoo-H deleted the Jinwoo-H/ssh branch April 14, 2026 02:23
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSH access to remote projects

1 participant