From e6af89741f3afbc5118ac69284df6b6d17a88db2 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 18:25:54 -0700 Subject: [PATCH] feat(ssh): a paired client can manage the server's own SSH hosts (ssh.target-management.v1) (#27329) * feat(ssh): manage a paired server's own SSH targets over RPC (ssh.target-management.v1) * fix(ssh): keep a browsed remote path untrimmed --------- Co-authored-by: m4air --- .../ipc/repos/repos-changed-notification.ts | 4 +- src/main/ipc/ssh-browse.ts | 274 +--------------- ...ssh-target-crud-handlers-ownership.test.ts | 12 +- src/main/ipc/ssh-target-crud-handlers.ts | 97 +++--- src/main/ipc/ssh.ts | 2 + src/main/runtime/rpc/methods/repo.test.ts | 44 +++ src/main/runtime/rpc/methods/repo.ts | 11 + .../rpc/methods/ssh-target-management.test.ts | 304 ++++++++++++++++++ src/main/runtime/rpc/methods/ssh.ts | 127 +++++++- ...time-project-host-setup-controller.test.ts | 14 + .../runtime-project-host-setup-controller.ts | 13 + .../runtime-repository-command-surface.ts | 2 + src/main/ssh/ssh-remote-directory-browse.ts | 276 ++++++++++++++++ src/main/ssh/ssh-target-registry.ts | 28 +- .../runtime-ssh-target-management.test.ts | 86 +++++ .../runtime/runtime-ssh-target-management.ts | 107 ++++++ src/shared/protocol-version.ts | 16 +- .../rpc-params-catalog.generated.ts | 8 +- src/shared/rpc-contract/ssh-params.ts | 57 ++++ src/shared/ssh-target-management.ts | 46 +++ 20 files changed, 1183 insertions(+), 345 deletions(-) create mode 100644 src/main/runtime/rpc/methods/ssh-target-management.test.ts create mode 100644 src/main/ssh/ssh-remote-directory-browse.ts create mode 100644 src/renderer/src/runtime/runtime-ssh-target-management.test.ts create mode 100644 src/renderer/src/runtime/runtime-ssh-target-management.ts create mode 100644 src/shared/ssh-target-management.ts diff --git a/src/main/ipc/repos/repos-changed-notification.ts b/src/main/ipc/repos/repos-changed-notification.ts index 3428fc87eb4..43aa0ae35a2 100644 --- a/src/main/ipc/repos/repos-changed-notification.ts +++ b/src/main/ipc/repos/repos-changed-notification.ts @@ -12,9 +12,9 @@ export function setRepoRemoteClientNotifier(notifier: RepoRemoteClientNotifier): repoRemoteClientNotifier = notifier } -export function notifyReposChanged(mainWindow: BrowserWindow): void { +export function notifyReposChanged(mainWindow: BrowserWindow | null): void { wakeFolderRepoGitUpgradeWatch() - if (!mainWindow.isDestroyed()) { + if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('repos:changed') } // Why: paired clients only refetch a remote catalog on this event; without it a diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index e626968ea06..d0a2d8ccf10 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -1,39 +1,7 @@ import { ipcMain } from 'electron' import type { SshConnectionManager } from '../ssh/ssh-connection-manager' -import type { SshExecOptions } from '../ssh/ssh-connection-utils' -import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' -import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' -import { sortDirEntries } from '../../shared/file-name-sort' -import { buildPosixStdoutFence } from '../../shared/posix-stdout-fence' +import { browseSshDirectory, type RemoteBrowseResult } from '../ssh/ssh-remote-directory-browse' -export type RemoteDirEntry = { - name: string - isDirectory: boolean -} - -type RemoteBrowseResult = { - entries: RemoteDirEntry[] - resolvedPath: string - pathFlavor: FilesystemPathFlavor -} - -const SSH_BROWSE_TIMEOUT_MS = 15_000 - -// Why: 127 = POSIX "command not found" (locale-independent) — the Windows fallback never ran, so the original POSIX error is the real one. -const POSIX_COMMAND_NOT_FOUND_EXIT = 127 - -// Carries the raw exit code so the fallback can distinguish 127 (no powershell.exe → not Windows) from a genuine PowerShell error. -class RemoteBrowseError extends Error { - constructor( - message: string, - readonly exitCode: number | null - ) { - super(message) - this.name = 'RemoteBrowseError' - } -} - -// Why: relay fs.readDir needs workspace-root ACLs that don't exist until a repo is added, so browse over raw SSH exec. export function registerSshBrowseHandler( getConnectionManager: () => SshConnectionManager | null ): void { @@ -41,243 +9,7 @@ export function registerSshBrowseHandler( ipcMain.handle( 'ssh:browseDir', - async (_event, args: { targetId: string; dirPath: string }): Promise => { - 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`) - } - - try { - return await browseWithPosixShell(conn, args.dirPath) - } catch (posixError) { - // Why: only a RemoteBrowseError (ran, non-zero exit) signals a Windows shell; don't retry transport errors/timeouts as Windows. - if (!(posixError instanceof RemoteBrowseError)) { - throw posixError - } - try { - return await browseWithWindowsPowerShell(conn, args.dirPath) - } catch (fallbackError) { - // Why: exit 127 (no powershell.exe) → host isn't Windows, surface the original POSIX failure; otherwise PowerShell's own error is the real cause. - throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError - } - } - } + (_event, args: { targetId: string; dirPath: string }): Promise => + browseSshDirectory(getConnectionManager(), args.targetId, args.dirPath) ) } - -type SshBrowseConnection = NonNullable> - -function browseWithPosixShell( - conn: SshBrowseConnection, - dirPath: string -): Promise { - // Why: `command ls` skips aliases; `&&` makes a failing ls exit non-zero (not look empty); -1Ap = one-per-line + trailing / on dirs. - return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`, 'posix') -} - -function browseWithWindowsPowerShell( - conn: SshBrowseConnection, - dirPath: string -): Promise { - if (/^[\\/]+$/.test(dirPath.trim())) { - const script = [ - "$ErrorActionPreference = 'Stop'", - '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - "Write-Output '/'", - "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Name -match '^[A-Za-z]$' } | Sort-Object Name | ForEach-Object { Write-Output (($_.Name.ToUpperInvariant() + ':\\') + '/') }" - ].join('; ') - return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) - } - - const script = [ - "$ErrorActionPreference = 'Stop'", - // Why: PowerShell 5.1 emits redirected stdout in the OEM code page; pin UTF-8 so non-ASCII names aren't mojibake. - '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - `$dir = ${powerShellPathExpression(dirPath)}`, - 'Set-Location -LiteralPath $dir', - '$resolved = (Get-Location).ProviderPath', - // Why: the renderer's parentPath/joinPath split only on `/`, so emit a forward-slash resolvedPath while keeping native $resolved for Get-ChildItem. - "Write-Output ($resolved -replace '\\\\', '/')", - 'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {', - " if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }", - '}' - ].join('; ') - - return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) -} - -async function runBrowseCommand( - conn: SshBrowseConnection, - command: string, - pathFlavor: FilesystemPathFlavor, - options?: SshExecOptions -): Promise { - // Why: login-shell rc output would otherwise be read as the resolved path. - const fence = options?.wrapCommand === false ? null : buildPosixStdoutFence(command, 'SSH') - const remoteCommand = fence?.command ?? command - const channel = options ? await conn.exec(remoteCommand, options) : await conn.exec(remoteCommand) - - return new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' - let exitCode: number | null = null - let settled = false - let timeout: ReturnType | null = null - - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - channel.off('data', onStdoutData) - channel.stderr.off('data', onStderrData) - channel.off('exit', onExit) - channel.off('close', onClose) - channel.off('error', onError) - channel.stderr.off('error', onError) - } - const rejectOnce = (error: Error): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) - } - const closeChannel = (): void => { - const closable = channel as { close?: () => void; destroy?: () => void } - try { - if (typeof closable.close === 'function') { - closable.close() - } else if (typeof closable.destroy === 'function') { - closable.destroy() - } - } catch { - /* best effort */ - } - } - const onTimeout = (): void => { - // Why: no relay deadline exists during add-project browsing, so bound this raw exec channel or Add Remote Project hangs forever. - rejectOnce(new Error('Remote directory listing timed out')) - closeChannel() - } - const resolveOnce = (result: RemoteBrowseResult): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve(result) - } - - const onStdoutData = (data: Buffer): void => { - stdout += data.toString() - } - const onStderrData = (data: Buffer): void => { - stderr += data.toString() - } - // `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing. - const onExit = (code: number | null): void => { - exitCode = code - } - const onError = (error: Error): void => { - rejectOnce(error) - } - const onClose = (): void => { - stdout = fence?.readStdout(stdout) ?? stdout - // Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir. - if (exitCode !== 0) { - const msg = - stderr.trim() || - (exitCode === null - ? 'Remote listing failed (channel closed without exit status)' - : `Remote listing failed (exit ${exitCode})`) - rejectOnce(new RemoteBrowseError(msg, exitCode)) - return - } - if (stderr.trim() && !stdout.trim()) { - rejectOnce(new Error(stderr.trim())) - return - } - - // Why: Windows OpenSSH exec emits CRLF; split on \r?\n so a trailing \r doesn't defeat the endsWith('/') dir check or leave a stray CR in names. - const lines = stdout.trim().split(/\r?\n/) - if (lines.length === 0) { - rejectOnce(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 natural name order (matches the Explorer) - sortDirEntries(entries) - - resolveOnce({ entries, resolvedPath, pathFlavor }) - } - - channel.on('data', onStdoutData) - channel.stderr.on('data', onStderrData) - channel.on('exit', onExit) - channel.on('close', onClose) - // Why: SSH exec streams emit `error` on transport loss; without a scoped listener a disappearing remote can become process-fatal. - channel.on('error', onError) - channel.stderr.on('error', onError) - timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) -} - -// Why: exit 127 means powershell.exe wasn't found — the host isn't Windows, so surface the original POSIX failure instead. -function isPosixCommandNotFound(error: unknown): boolean { - return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT -} - -// Why: single-quote to block shell injection; ~ needs $HOME since single quotes suppress tilde 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, "'\\''")}'` -} - -function powerShellPathExpression(s: string): string { - if (s === '~') { - return '$HOME' - } - if (s.startsWith('~/') || s.startsWith('~\\')) { - return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}` - } - return powerShellLiteral(normalizeWindowsDrivePath(s)) -} - -// Why: renderer's POSIX path rebuild yields '/C:/…' or bare 'C:' (drive-relative), both mis-resolved by Set-Location; re-root to a proper drive path. -function normalizeWindowsDrivePath(s: string): string { - const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '') - return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped -} diff --git a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts b/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts index 630615f001c..15d2c53fd91 100644 --- a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts +++ b/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => { state, addTarget: vi.fn(), updateTarget: vi.fn(), + setMutations: vi.fn(), closeTunnel: vi.fn(async () => {}), disconnect: vi.fn(async () => {}) } @@ -21,7 +22,8 @@ vi.mock('../ssh/ssh-target-registry', () => ({ addTarget: mocks.addTarget, updateTarget: mocks.updateTarget, lastRepoReadoptions: [] - }) + }), + setSshTargetMutations: mocks.setMutations })) vi.mock('./ssh-session-teardown', () => ({ removeRegisteredSshTarget: mocks.remove })) vi.mock('../ssh/orcad-managed-tunnel', () => ({ closeOrcadManagedTunnel: mocks.closeTunnel })) @@ -92,6 +94,14 @@ describe('SSH target CRUD against managed orcad targets', () => { expect(mocks.remove).not.toHaveBeenCalled() }) + it('gives paired clients the same rules: a managed host is refused there too', async () => { + const mutations = mocks.setMutations.mock.calls.at(-1)?.[0] + await expect(mutations.remove('ssh-1')).rejects.toThrow('Settings › Managed servers') + mutations.update('ssh-1', { host: 'elsewhere', generation: 9 }) + expect(mocks.updateTarget).toHaveBeenCalledWith('ssh-1', { host: 'elsewhere' }) + expect(mocks.remove).not.toHaveBeenCalled() + }) + it('never lets the renderer write a provisioning intent', () => { mocks.state.target = target handler('ssh:updateTarget')(null, { diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 010bc663590..2a842b943d7 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -2,6 +2,8 @@ import { ipcMain } from 'electron' import type { SshConfigHostListArgs, SshRepoReadoption, + SshTarget, + SshTargetAddResult, SshTargetCreateInput, SshTargetUpdateInput } from '../../shared/ssh-types' @@ -12,7 +14,7 @@ import { import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' import { closeOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel' import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' +import { getSshTargetRegistryStore, setSshTargetMutations } from '../ssh/ssh-target-registry' import { allowsDirectSshRelay, isManagedOrcadSshTarget, @@ -22,6 +24,7 @@ import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' import { runTargetLifecycle } from './ssh-target-lifecycle-queue' import { fingerprintRuntimeSshTarget } from '../ssh/runtime-ssh-access' import { removeRegisteredSshTarget } from './ssh-session-teardown' +import { notifyReposChanged } from './repos/repos-changed-notification' // Why: add/import can re-adopt workspaces orphaned on a removed target id (see ssh-target-readoption); the renderer must refresh its repo list to surface them. function takeRepoReadoptions(): SshRepoReadoption[] { @@ -36,10 +39,8 @@ function takeRepoReadoptions(): SshRepoReadoption[] { )) { rotateSshProviderAuthority(targetId) } - const win = getCurrentMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('repos:changed') - } + // Why: paired clients refetch repos only on this event, and they may have made the change. + notifyReposChanged(getCurrentMainWindow()) return repoReadoptions } @@ -97,7 +98,49 @@ export function assertNotManagedServerHost(targetId: string): void { } } +function addSshTarget(input: SshTargetCreateInput): SshTargetAddResult { + const target = getSshTargetRegistryStore()!.addTarget(omitRendererSshTargetGeneration(input)) + // Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host. + const repoReadoptions = takeRepoReadoptions() + return { target, repoReadoptions } +} + +function updateSshTarget(id: string, updates: SshTargetUpdateInput): SshTarget | null { + assertNotRuntimeOwned(id, 'edited') + const before = getSshTargetRegistryStore()!.getTarget(id) + // The fence and generation are stripped, so a managed host keeps its server binding. + const updated = getSshTargetRegistryStore()!.updateTarget( + id, + omitRendererSshTargetGeneration(updates) + ) + const environmentId = getManagedOrcadFenceEnvironmentId(updated ?? undefined) + if (environmentId && updated) { + // Why: the tunnel and the SSH transport under it were built from the old fields, so both go + // and the next use dials the edited target. Only a host reached through its managed server: + // one an older build changed runs on the relay directly, whose session owns the transport. + const redial = + !allowsDirectSshRelay(updated) && + (!before || fingerprintRuntimeSshTarget(before) !== fingerprintRuntimeSshTarget(updated)) + void runTargetLifecycle(id, async () => { + await closeOrcadManagedTunnel(environmentId) + if (redial) { + await connectionManager?.disconnect(id) + } + }).catch(() => undefined) + } + return updated +} + +async function removeSshTarget(id: string): Promise { + assertNotRuntimeOwned(id, 'removed') + assertNotManagedServerHost(id) + await removeRegisteredSshTarget(id) +} + export function registerSshTargetCrudHandlers(): void { + // Why: paired clients manage this host's targets over RPC with the same rules as SSH settings. + setSshTargetMutations({ add: addSshTarget, update: updateSshTarget, remove: removeSshTarget }) + ipcMain.handle('ssh:listTargets', () => { return getSshTargetRegistryStore()!.listTargets() }) @@ -106,49 +149,17 @@ export function registerSshTargetCrudHandlers(): void { return getSshTargetRegistryStore()!.listRemovedTargetLabels() }) - ipcMain.handle('ssh:addTarget', (_event, args: { target: SshTargetCreateInput }) => { - const target = getSshTargetRegistryStore()!.addTarget( - omitRendererSshTargetGeneration(args.target) - ) - // Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host. - const repoReadoptions = takeRepoReadoptions() - return { target, repoReadoptions } - }) + ipcMain.handle('ssh:addTarget', (_event, args: { target: SshTargetCreateInput }) => + addSshTarget(args.target) + ) ipcMain.handle( 'ssh:updateTarget', - (_event, args: { id: string; updates: SshTargetUpdateInput }) => { - assertNotRuntimeOwned(args.id, 'edited') - const before = getSshTargetRegistryStore()!.getTarget(args.id) - // The fence and generation are stripped, so a managed host keeps its server binding. - const updated = getSshTargetRegistryStore()!.updateTarget( - args.id, - omitRendererSshTargetGeneration(args.updates) - ) - const environmentId = getManagedOrcadFenceEnvironmentId(updated ?? undefined) - if (environmentId && updated) { - // Why: the tunnel and the SSH transport under it were built from the old fields, so both go - // and the next use dials the edited target. Only a host reached through its managed server: - // one an older build changed runs on the relay directly, whose session owns the transport. - const redial = - !allowsDirectSshRelay(updated) && - (!before || fingerprintRuntimeSshTarget(before) !== fingerprintRuntimeSshTarget(updated)) - void runTargetLifecycle(args.id, async () => { - await closeOrcadManagedTunnel(environmentId) - if (redial) { - await connectionManager?.disconnect(args.id) - } - }).catch(() => undefined) - } - return updated - } + (_event, args: { id: string; updates: SshTargetUpdateInput }) => + updateSshTarget(args.id, args.updates) ) - ipcMain.handle('ssh:removeTarget', async (_event, args: { id: string }) => { - assertNotRuntimeOwned(args.id, 'removed') - assertNotManagedServerHost(args.id) - await removeRegisteredSshTarget(args.id) - }) + ipcMain.handle('ssh:removeTarget', (_event, args: { id: string }) => removeSshTarget(args.id)) ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { const targets = getSshTargetRegistryStore()!.importFromSshConfig(args) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index cb993f03e2a..816423e1aa6 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -17,6 +17,7 @@ import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { getSshTargetRegistryStore, setSshConnectionManagerResolver, + setSshTargetMutations, setSshTargetRegistryHandlers, setSshTargetRegistryStore } from '../ssh/ssh-target-registry' @@ -270,6 +271,7 @@ export async function resetSshHandlerStateForTests(): Promise { setSshTargetRegistryStore(null) setPersistedStore(null) setSshTargetRegistryHandlers({ connect: null, getState: null }) + setSshTargetMutations(null) setCurrentGetMainWindow(() => null) setCurrentRuntime(undefined) } diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 687b7ba5030..3c629c58aaa 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -12,6 +12,7 @@ import { } from '../../../../shared/protocol-version' import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' import { REPO_SEARCH_REFS_MAX_LIMIT } from '../../../../shared/repo-search-limits' +import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from '../../../../shared/ssh-target-management' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } @@ -160,6 +161,49 @@ describe('repo RPC methods', () => { ) }) + it('advertises server SSH target management so clients can gate the remote project flow', () => { + expect(RUNTIME_CAPABILITIES).toContain(SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY) + }) + + // Harvested from the community PR #8492 by @jae-heo. + it('adds an existing folder on one of the server SSH targets as a project', async () => { + const repo = { + id: 'repo-p8', + path: '/srv/project', + displayName: 'project', + badgeColor: '#737373', + addedAt: 1, + connectionId: 'ssh-p8', + kind: 'git' as const + } + const runtime = new OrcaRuntimeService(null) + const addSshRepo = vi.spyOn(runtime, 'addSshRepo').mockResolvedValue(repo) + const addRepo = vi.spyOn(runtime, 'addRepo') + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.addRemote', { connectionId: 'ssh-p8', remotePath: '/srv/project' }), + { clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] } + ) + + expect(addSshRepo).toHaveBeenCalledWith({ connectionId: 'ssh-p8', remotePath: '/srv/project' }) + expect(addRepo).not.toHaveBeenCalled() + expect(response).toMatchObject({ ok: true, result: { repo: { connectionId: 'ssh-p8' } } }) + }) + + it('refuses a remote project without an SSH target rather than adding a server path', async () => { + const runtime = new OrcaRuntimeService(null) + const addSshRepo = vi.spyOn(runtime, 'addSshRepo') + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.addRemote', { remotePath: '/srv/project' }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(addSshRepo).not.toHaveBeenCalled() + }) + it('updates project runtime preferences on the runtime server', async () => { const project = { id: 'project-1', diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 42563096896..ffb9e0c6d74 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -27,6 +27,7 @@ import { RepoSparsePresetSave, RepoUpdate } from '../../../../shared/rpc-contract/repo-params' +import { RepoAddRemote } from '../../../../shared/rpc-contract/ssh-params' export const REPO_METHODS = [ defineMethod({ @@ -126,6 +127,16 @@ export const REPO_METHODS = [ ) }) }), + // Why a new method, not a field on repo.add: an old host would strip the field and register the + // path on itself instead of on its SSH target. Gated by ssh.target-management.v1. + defineMethod({ + name: 'repo.addRemote', + permission: 'workspace', + params: RepoAddRemote, + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient(await context.runtime.addSshRepo(params), context) + }) + }), defineMethod({ name: 'repo.create', permission: 'workspace', diff --git a/src/main/runtime/rpc/methods/ssh-target-management.test.ts b/src/main/runtime/rpc/methods/ssh-target-management.test.ts new file mode 100644 index 00000000000..ddba0166d40 --- /dev/null +++ b/src/main/runtime/rpc/methods/ssh-target-management.test.ts @@ -0,0 +1,304 @@ +import '../unused-default-rpc-methods.test-fixture' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { RpcCallerScope } from '../rpc-caller-scope' +import { OrcaRuntimeService } from '../../orca-runtime' +import { SSH_EDITABLE_TARGET_FIELDS } from '../../../../shared/ssh-target-management' +import { SSH_METHODS } from './ssh' + +const mocks = vi.hoisted(() => ({ + getRegisteredSshState: vi.fn(), + listRegisteredSshTargets: vi.fn(), + mutations: { add: vi.fn(), update: vi.fn(), remove: vi.fn() }, + getSshConnectionManager: vi.fn(), + browseSshDirectory: vi.fn() +})) + +vi.mock('../../../ssh/ssh-target-registry', () => ({ + connectRegisteredSshTarget: vi.fn(), + getRegisteredSshState: mocks.getRegisteredSshState, + getRegisteredSshTargetMutations: () => mocks.mutations, + getSshConnectionManager: mocks.getSshConnectionManager, + listRegisteredRemovedSshTargetLabels: vi.fn(() => ({})), + listRegisteredSshTargets: mocks.listRegisteredSshTargets +})) + +vi.mock('../../../ssh/ssh-remote-directory-browse', () => ({ + browseSshDirectory: mocks.browseSshDirectory +})) + +const SECRET_PASSWORD = 'hunter2-password' +const SECRET_PASSPHRASE = 'key-passphrase-value' + +// A stored row carrying keys no paired client may see: secrets (as a malformed or future profile +// might hold) and main-owned fields. +const storedTarget = { + id: 'ssh-1', + label: 'Dev box', + host: 'dev.internal', + port: 2222, + username: 'me', + identityFile: '~/.ssh/id_ed25519', + jumpHost: 'bastion', + source: 'manual', + generation: 4, + password: SECRET_PASSWORD, + passphrase: SECRET_PASSPHRASE, + lastRequiredPassphrase: true, + remoteRuntimeResolution: { rung: 'A' }, + owner: undefined +} + +const validInput = { label: 'Build', host: 'build.internal', port: 22, username: 'ci' } + +const runtime = new OrcaRuntimeService() + +function dispatch(method: string, params?: unknown, callerScope?: RpcCallerScope) { + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS, callerScope }) + const request: RpcRequest = { id: 'req-1', authToken: 'tok', method, params } + return dispatcher.dispatch(request) +} + +function expectNoSecrets(value: unknown): void { + const serialized = JSON.stringify(value) + expect(serialized).not.toContain(SECRET_PASSWORD) + expect(serialized).not.toContain(SECRET_PASSPHRASE) + expect(serialized).not.toContain('lastRequiredPassphrase') + expect(serialized).not.toContain('remoteRuntimeResolution') +} + +function listedTargetKeys(response: unknown): string[] { + const parsed: unknown = JSON.parse(JSON.stringify(response)) + if (typeof parsed !== 'object' || parsed === null || !('result' in parsed)) { + return [] + } + const { result } = parsed + if (typeof result !== 'object' || result === null || !('targets' in result)) { + return [] + } + const [first] = Array.isArray(result.targets) ? result.targets : [] + return typeof first === 'object' && first !== null ? Object.keys(first) : [] +} + +const ALLOWED_VIEW_KEYS = new Set([ + ...SSH_EDITABLE_TARGET_FIELDS, + 'id', + 'generation', + 'connected', + 'connectionStatus', + 'remotePlatform', + 'source', + 'managedServer' +]) + +describe('server SSH target management RPC', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getRegisteredSshState.mockReturnValue({ status: 'connected' }) + }) + + it('lists editable fields only; secrets and main-owned fields never leave the server', async () => { + mocks.listRegisteredSshTargets.mockReturnValue([storedTarget]) + + const response = await dispatch('ssh.listEditableTargets') + + expect(response).toMatchObject({ + ok: true, + result: { + targets: [ + { + id: 'ssh-1', + label: 'Dev box', + host: 'dev.internal', + port: 2222, + username: 'me', + identityFile: '~/.ssh/id_ed25519', + jumpHost: 'bastion', + generation: 4, + connected: true, + connectionStatus: 'connected', + source: 'manual' + } + ] + } + }) + expectNoSecrets(response) + expect(listedTargetKeys(response).filter((key) => !ALLOWED_VIEW_KEYS.has(key))).toEqual([]) + }) + + it('marks a managed-server host so the client does not offer removal', async () => { + mocks.listRegisteredSshTargets.mockReturnValue([ + { ...validInput, id: 'ssh-2', orcadFence: { environmentId: 'env-1' } } + ]) + + const response = await dispatch('ssh.listEditableTargets') + + expect(response).toMatchObject({ ok: true, result: { targets: [{ managedServer: true }] } }) + expect(JSON.stringify(response)).not.toContain('orcadFence') + }) + + it.each([ + ['password', SECRET_PASSWORD], + ['passphrase', SECRET_PASSPHRASE] + ])('refuses a %s on add or update instead of dropping it silently', async (key, value) => { + const add = await dispatch('ssh.addTarget', { target: { ...validInput, [key]: value } }) + const update = await dispatch('ssh.updateTarget', { + targetId: 'ssh-1', + target: { ...validInput, [key]: value } + }) + + expect(add).toMatchObject({ ok: false }) + expect(update).toMatchObject({ ok: false }) + expect(mocks.mutations.add).not.toHaveBeenCalled() + expect(mocks.mutations.update).not.toHaveBeenCalled() + expectNoSecrets([add, update]) + }) + + it('refuses main-owned fields a client must not forge', async () => { + const response = await dispatch('ssh.addTarget', { + target: { ...validInput, generation: 99, orcadFence: { environmentId: 'env-1' } } + }) + + expect(response).toMatchObject({ ok: false }) + expect(mocks.mutations.add).not.toHaveBeenCalled() + }) + + it('adds through the shared mutation and returns the redacted view', async () => { + mocks.mutations.add.mockReturnValue({ + target: { ...storedTarget, ...validInput, id: 'ssh-new' }, + repoReadoptions: [{ oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['r1'] }] + }) + + const response = await dispatch('ssh.addTarget', { target: validInput }) + + expect(mocks.mutations.add).toHaveBeenCalledWith({ ...validInput, source: 'manual' }) + expect(response).toMatchObject({ + ok: true, + result: { + target: { id: 'ssh-new', host: 'build.internal' }, + repoReadoptions: [{ newTargetId: 'ssh-new', repoIds: ['r1'] }] + } + }) + expectNoSecrets(response) + }) + + it('updates with replace semantics so an omitted optional field is cleared', async () => { + mocks.mutations.update.mockReturnValue({ ...storedTarget, ...validInput }) + + const response = await dispatch('ssh.updateTarget', { targetId: 'ssh-1', target: validInput }) + + const [targetId, updates] = mocks.mutations.update.mock.calls[0] + expect(targetId).toBe('ssh-1') + expect(updates).toMatchObject({ ...validInput, source: 'manual' }) + expect(Object.keys(updates)).toEqual(expect.arrayContaining(['identityFile', 'jumpHost'])) + expect(updates.identityFile).toBeUndefined() + expect(updates.jumpHost).toBeUndefined() + expect(response).toMatchObject({ ok: true }) + expectNoSecrets(response) + }) + + it('reports an unknown target instead of answering success', async () => { + mocks.mutations.update.mockReturnValue(null) + + const response = await dispatch('ssh.updateTarget', { targetId: 'gone', target: validInput }) + + expect(response).toMatchObject({ ok: false, error: { message: 'SSH target not found: gone' } }) + }) + + it('removes through the shared mutation and surfaces its refusal', async () => { + mocks.mutations.remove.mockResolvedValueOnce(undefined) + await expect(dispatch('ssh.removeTarget', { targetId: 'ssh-1' })).resolves.toMatchObject({ + ok: true, + result: { removed: true } + }) + mocks.mutations.remove.mockRejectedValueOnce(new Error('This host runs a managed Orca server.')) + await expect(dispatch('ssh.removeTarget', { targetId: 'ssh-2' })).resolves.toMatchObject({ + ok: false + }) + }) + + it('browses a server SSH target over the server connection manager', async () => { + const manager = { getConnection: vi.fn() } + mocks.getSshConnectionManager.mockReturnValue(manager) + mocks.browseSshDirectory.mockResolvedValue({ + entries: [{ name: 'src', isDirectory: true }], + resolvedPath: '/home/me', + pathFlavor: 'posix' + }) + + const response = await dispatch('ssh.browseDir', { targetId: 'ssh-1', dirPath: '~' }) + + expect(mocks.browseSshDirectory).toHaveBeenCalledWith(manager, 'ssh-1', '~') + expect(response).toMatchObject({ ok: true, result: { resolvedPath: '/home/me' } }) + }) + + it('publishes no client event carrying target data', async () => { + mocks.listRegisteredSshTargets.mockReturnValue([storedTarget]) + mocks.mutations.add.mockReturnValue({ target: storedTarget, repoReadoptions: [] }) + mocks.mutations.update.mockReturnValue(storedTarget) + mocks.mutations.remove.mockResolvedValue(undefined) + + const events: unknown[] = [] + const unsubscribe = runtime.onClientEvent((event) => events.push(event)) + + await dispatch('ssh.listEditableTargets') + await dispatch('ssh.addTarget', { target: validInput }) + await dispatch('ssh.updateTarget', { targetId: 'ssh-1', target: validInput }) + await dispatch('ssh.removeTarget', { targetId: 'ssh-1' }) + unsubscribe() + + expectNoSecrets(events) + expect(JSON.stringify(events)).not.toContain('dev.internal') + }) + + describe('who may manage', () => { + const MANAGEMENT_CALLS: [string, unknown][] = [ + ['ssh.listEditableTargets', undefined], + ['ssh.addTarget', { target: validInput }], + ['ssh.updateTarget', { targetId: 'ssh-1', target: validInput }], + ['ssh.removeTarget', { targetId: 'ssh-1' }], + ['ssh.browseDir', { targetId: 'ssh-1', dirPath: '~' }] + ] + + beforeEach(() => { + mocks.listRegisteredSshTargets.mockReturnValue([]) + mocks.mutations.add.mockReturnValue({ target: storedTarget, repoReadoptions: [] }) + mocks.mutations.update.mockReturnValue(storedTarget) + mocks.mutations.remove.mockResolvedValue(undefined) + mocks.browseSshDirectory.mockResolvedValue({ + entries: [], + resolvedPath: '/', + pathFlavor: 'posix' + }) + }) + + it.each(MANAGEMENT_CALLS)( + 'a paired desktop or web client may call %s', + async (method, params) => { + await expect( + dispatch(method, params, { kind: 'runtime-paired', grants: [] }) + ).resolves.toMatchObject({ ok: true }) + } + ) + + it.each(MANAGEMENT_CALLS)('a phone may not call %s', async (method, params) => { + await expect(dispatch(method, params, { kind: 'mobile' })).resolves.toMatchObject({ + ok: false + }) + }) + + it.each(MANAGEMENT_CALLS)( + "an SSH host's CLI without remote control may not call %s", + async (method, params) => { + await expect( + dispatch(method, params, { + kind: 'ssh-bridge', + targetId: 'ssh-1', + remoteCliControl: false + }) + ).resolves.toMatchObject({ ok: false }) + } + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 0f2e0b606c9..e2bce335a98 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -1,28 +1,75 @@ import { connectRegisteredSshTarget, getRegisteredSshState, + getRegisteredSshTargetMutations, + getSshConnectionManager, listRegisteredRemovedSshTargetLabels, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry' +import { isManagedOrcadSshTarget } from '../../../ssh/ssh-connection-store' +import { browseSshDirectory } from '../../../ssh/ssh-remote-directory-browse' import { defineMethod } from '../core' import { getPublicSshError, getPublicSshState } from '../../public-ssh-state' -import type { SshTargetSummary } from '../../../../shared/ssh-types' -import { SshTarget } from '../../../../shared/rpc-contract/ssh-params' +import type { SshTarget as SshTargetRecord, SshTargetSummary } from '../../../../shared/ssh-types' +import type { + SshEditableTarget, + SshEditableTargetField, + SshEditableTargetInput +} from '../../../../shared/ssh-target-management' +import { + SshBrowseDir, + SshTarget, + SshTargetAdd, + SshTargetUpdate +} from '../../../../shared/rpc-contract/ssh-params' // Why: `generation` stays optional on the wire — an old server simply omits it and its rows key on target id alone. +function toSshTargetSummary({ id, label, generation }: SshTargetRecord): SshTargetSummary { + const state = getRegisteredSshState(id) + const remotePlatform = state?.remotePlatform + return { + id, + label, + ...(generation === undefined ? {} : { generation }), + connected: state?.status === 'connected', + ...(state?.status === undefined ? {} : { connectionStatus: state.status }), + ...(remotePlatform === undefined ? {} : { remotePlatform }) + } +} + function listRegisteredSshTargetSummaries(): SshTargetSummary[] { - return listRegisteredSshTargets().map(({ id, label, generation }) => { - const state = getRegisteredSshState(id) - const remotePlatform = state?.remotePlatform - return { - id, - label, - ...(generation === undefined ? {} : { generation }), - connected: state?.status === 'connected', - ...(state?.status === undefined ? {} : { connectionStatus: state.status }), - ...(remotePlatform === undefined ? {} : { remotePlatform }) - } - }) + return listRegisteredSshTargets().map(toSshTargetSummary) +} + +// Why an allowlist: a field added to SshTarget later (or a stray key in an old profile) must not +// reach paired clients by default. `satisfies` keeps it in step with SSH_EDITABLE_TARGET_FIELDS. +function pickSshEditableFields(source: SshEditableTargetInput): SshEditableTargetInput { + return { + label: source.label, + configHost: source.configHost, + host: source.host, + port: source.port, + username: source.username, + identityFile: source.identityFile, + identityAgent: source.identityAgent, + identitiesOnly: source.identitiesOnly, + gssapiAuthentication: source.gssapiAuthentication, + proxyCommand: source.proxyCommand, + jumpHost: source.jumpHost, + relayGracePeriodSeconds: source.relayGracePeriodSeconds, + systemSshConnectionReuse: source.systemSshConnectionReuse, + remoteRuntime: source.remoteRuntime, + allowRemoteCliControl: source.allowRemoteCliControl + } satisfies Record +} + +function toSshEditableTarget(target: SshTargetRecord): SshEditableTarget { + return { + ...pickSshEditableFields(target), + ...toSshTargetSummary(target), + ...(target.source === undefined ? {} : { source: target.source }), + ...(isManagedOrcadSshTarget(target) ? { managedServer: true as const } : {}) + } } export const SSH_METHODS = [ @@ -66,5 +113,57 @@ export const SSH_METHODS = [ permission: 'workspace', params: null, handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() }) + }), + // Why `workspace`: a paired client that can open terminals here can already edit ~/.ssh/config, + // so managing this host's SSH targets is the same power (rpc-method-permission.ts). + defineMethod({ + name: 'ssh.listEditableTargets', + permission: 'workspace', + params: null, + handler: () => ({ targets: listRegisteredSshTargets().map(toSshEditableTarget) }) + }), + defineMethod({ + name: 'ssh.addTarget', + permission: 'workspace', + params: SshTargetAdd, + handler: (params) => { + const result = getRegisteredSshTargetMutations().add({ ...params.target, source: 'manual' }) + return { + target: toSshEditableTarget(result.target), + repoReadoptions: result.repoReadoptions + } + } + }), + defineMethod({ + name: 'ssh.updateTarget', + permission: 'workspace', + params: SshTargetUpdate, + handler: (params) => { + // Why: replace semantics, so an omitted optional field is cleared, as SSH settings do. + const updated = getRegisteredSshTargetMutations().update(params.targetId, { + ...pickSshEditableFields(params.target), + source: 'manual' + }) + if (!updated) { + throw new Error(`SSH target not found: ${params.targetId}`) + } + return { target: toSshEditableTarget(updated) } + } + }), + defineMethod({ + name: 'ssh.removeTarget', + permission: 'workspace', + params: SshTarget, + handler: async (params) => { + await getRegisteredSshTargetMutations().remove(params.targetId) + return { removed: true } + } + }), + defineMethod({ + name: 'ssh.browseDir', + permission: 'workspace', + params: SshBrowseDir, + handler: (params) => + browseSshDirectory(getSshConnectionManager(), params.targetId, params.dirPath) }) ] diff --git a/src/main/runtime/runtime-project-host-setup-controller.test.ts b/src/main/runtime/runtime-project-host-setup-controller.test.ts index 6defe84800b..114f0069904 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.test.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.test.ts @@ -76,6 +76,20 @@ describe('RuntimeProjectHostSetupController host routing', () => { expect(result.repo.id).toBe(remoteRepo.id) }) + it('adds a new project from a folder on an SSH target, never as a local path', async () => { + const { controller, addRepo, addRemoteRepo } = makeController() + + const repo = await controller.addSshRepo({ connectionId: TARGET_ID, remotePath: REMOTE_PATH }) + + expect(addRemoteRepo).toHaveBeenCalledWith({ + connectionId: TARGET_ID, + remotePath: REMOTE_PATH, + kind: 'git' + }) + expect(addRepo).not.toHaveBeenCalled() + expect(repo.connectionId).toBe(TARGET_ID) + }) + it('decodes a percent-encoded SSH target back to its connection id', async () => { const { controller, addRemoteRepo, projectId } = makeController() diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index c3661f06acd..6dff6bae984 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -121,6 +121,19 @@ export class RuntimeProjectHostSetupController { return this.completeSetup(args, repo, !knownRepoIds.has(repo.id)) } + /** Registers an existing folder on one of this host's SSH targets as a new project. */ + addSshRepo(args: { + connectionId: string + remotePath: string + displayName?: string + kind?: 'folder' | 'git' + }): Promise { + if (!this.deps.getStore()) { + throw new Error('runtime_unavailable') + } + return this.deps.addRemoteRepo({ ...args, kind: args.kind === 'folder' ? 'folder' : 'git' }) + } + async setupClone(args: ProjectHostSetupCloneArgs): Promise { assertCloneHostIsSupported(args.hostId) const knownRepoIds = new Set(this.deps.listRepos().map((repo) => repo.id)) diff --git a/src/main/runtime/runtime-repository-command-surface.ts b/src/main/runtime/runtime-repository-command-surface.ts index 8820e4e945b..78d6a38abaa 100644 --- a/src/main/runtime/runtime-repository-command-surface.ts +++ b/src/main/runtime/runtime-repository-command-surface.ts @@ -40,6 +40,7 @@ export type RuntimeRepositoryCommandSurface = { createProjectHostSetup: RuntimeProjectHostSetupController['createSetup'] setupProjectExistingFolder: RuntimeProjectHostSetupController['setupExistingFolder'] setupProjectClone: RuntimeProjectHostSetupController['setupClone'] + addSshRepo: RuntimeProjectHostSetupController['addSshRepo'] updateProjectHostSetup: RuntimeProjectHostSetupController['updateSetup'] deleteProjectHostSetup: RuntimeProjectHostSetupController['deleteSetup'] listProjectGroups: RuntimeProjectGroupController['listGroups'] @@ -115,6 +116,7 @@ export function installRuntimeRepositoryCommandSurface( createProjectHostSetup: host.createSetup.bind(host), setupProjectExistingFolder: host.setupExistingFolder.bind(host), setupProjectClone: host.setupClone.bind(host), + addSshRepo: host.addSshRepo.bind(host), updateProjectHostSetup: host.updateSetup.bind(host), deleteProjectHostSetup: host.deleteSetup.bind(host), listProjectGroups: groups.listGroups.bind(groups), diff --git a/src/main/ssh/ssh-remote-directory-browse.ts b/src/main/ssh/ssh-remote-directory-browse.ts new file mode 100644 index 00000000000..b1fd912abbb --- /dev/null +++ b/src/main/ssh/ssh-remote-directory-browse.ts @@ -0,0 +1,276 @@ +import type { SshConnectionManager } from './ssh-connection-manager' +import type { SshExecOptions } from './ssh-connection-utils' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' +import { sortDirEntries } from '../../shared/file-name-sort' +import { buildPosixStdoutFence } from '../../shared/posix-stdout-fence' + +export type RemoteDirEntry = { + name: string + isDirectory: boolean +} + +export type RemoteBrowseResult = { + entries: RemoteDirEntry[] + resolvedPath: string + pathFlavor: FilesystemPathFlavor +} + +const SSH_BROWSE_TIMEOUT_MS = 15_000 + +// Why: 127 = POSIX "command not found" (locale-independent) — the Windows fallback never ran, so the original POSIX error is the real one. +const POSIX_COMMAND_NOT_FOUND_EXIT = 127 + +// Carries the raw exit code so the fallback can distinguish 127 (no powershell.exe → not Windows) from a genuine PowerShell error. +class RemoteBrowseError extends Error { + constructor( + message: string, + readonly exitCode: number | null + ) { + super(message) + this.name = 'RemoteBrowseError' + } +} + +// Why: relay fs.readDir needs workspace-root ACLs that don't exist until a repo is added, so browse over raw SSH exec. +export async function browseSshDirectory( + manager: SshConnectionManager | null, + targetId: string, + dirPath: string +): Promise { + if (!manager) { + throw new Error('SSH connection manager not initialized') + } + const conn = manager.getConnection(targetId) + if (!conn) { + throw new Error(`SSH connection "${targetId}" not found`) + } + + try { + return await browseWithPosixShell(conn, dirPath) + } catch (posixError) { + // Why: only a RemoteBrowseError (ran, non-zero exit) signals a Windows shell; don't retry transport errors/timeouts as Windows. + if (!(posixError instanceof RemoteBrowseError)) { + throw posixError + } + try { + return await browseWithWindowsPowerShell(conn, dirPath) + } catch (fallbackError) { + // Why: exit 127 (no powershell.exe) → host isn't Windows, surface the original POSIX failure; otherwise PowerShell's own error is the real cause. + throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError + } + } +} + +type SshBrowseConnection = NonNullable> + +function browseWithPosixShell( + conn: SshBrowseConnection, + dirPath: string +): Promise { + // Why: `command ls` skips aliases; `&&` makes a failing ls exit non-zero (not look empty); -1Ap = one-per-line + trailing / on dirs. + return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`, 'posix') +} + +function browseWithWindowsPowerShell( + conn: SshBrowseConnection, + dirPath: string +): Promise { + if (/^[\\/]+$/.test(dirPath.trim())) { + const script = [ + "$ErrorActionPreference = 'Stop'", + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + "Write-Output '/'", + "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Name -match '^[A-Za-z]$' } | Sort-Object Name | ForEach-Object { Write-Output (($_.Name.ToUpperInvariant() + ':\\') + '/') }" + ].join('; ') + return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) + } + + const script = [ + "$ErrorActionPreference = 'Stop'", + // Why: PowerShell 5.1 emits redirected stdout in the OEM code page; pin UTF-8 so non-ASCII names aren't mojibake. + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + `$dir = ${powerShellPathExpression(dirPath)}`, + 'Set-Location -LiteralPath $dir', + '$resolved = (Get-Location).ProviderPath', + // Why: the renderer's parentPath/joinPath split only on `/`, so emit a forward-slash resolvedPath while keeping native $resolved for Get-ChildItem. + "Write-Output ($resolved -replace '\\\\', '/')", + 'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {', + " if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }", + '}' + ].join('; ') + + return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) +} + +async function runBrowseCommand( + conn: SshBrowseConnection, + command: string, + pathFlavor: FilesystemPathFlavor, + options?: SshExecOptions +): Promise { + // Why: login-shell rc output would otherwise be read as the resolved path. + const fence = options?.wrapCommand === false ? null : buildPosixStdoutFence(command, 'SSH') + const remoteCommand = fence?.command ?? command + const channel = options ? await conn.exec(remoteCommand, options) : await conn.exec(remoteCommand) + + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let exitCode: number | null = null + let settled = false + let timeout: ReturnType | null = null + + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('exit', onExit) + channel.off('close', onClose) + channel.off('error', onError) + channel.stderr.off('error', onError) + } + const rejectOnce = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const closeChannel = (): void => { + const closable = channel as { close?: () => void; destroy?: () => void } + try { + if (typeof closable.close === 'function') { + closable.close() + } else if (typeof closable.destroy === 'function') { + closable.destroy() + } + } catch { + /* best effort */ + } + } + const onTimeout = (): void => { + // Why: no relay deadline exists during add-project browsing, so bound this raw exec channel or Add Remote Project hangs forever. + rejectOnce(new Error('Remote directory listing timed out')) + closeChannel() + } + const resolveOnce = (result: RemoteBrowseResult): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(result) + } + + const onStdoutData = (data: Buffer): void => { + stdout += data.toString() + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString() + } + // `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing. + const onExit = (code: number | null): void => { + exitCode = code + } + const onError = (error: Error): void => { + rejectOnce(error) + } + const onClose = (): void => { + stdout = fence?.readStdout(stdout) ?? stdout + // Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir. + if (exitCode !== 0) { + const msg = + stderr.trim() || + (exitCode === null + ? 'Remote listing failed (channel closed without exit status)' + : `Remote listing failed (exit ${exitCode})`) + rejectOnce(new RemoteBrowseError(msg, exitCode)) + return + } + if (stderr.trim() && !stdout.trim()) { + rejectOnce(new Error(stderr.trim())) + return + } + + // Why: Windows OpenSSH exec emits CRLF; split on \r?\n so a trailing \r doesn't defeat the endsWith('/') dir check or leave a stray CR in names. + const lines = stdout.trim().split(/\r?\n/) + if (lines.length === 0) { + rejectOnce(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 natural name order (matches the Explorer) + sortDirEntries(entries) + + resolveOnce({ entries, resolvedPath, pathFlavor }) + } + + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('exit', onExit) + channel.on('close', onClose) + // Why: SSH exec streams emit `error` on transport loss; without a scoped listener a disappearing remote can become process-fatal. + channel.on('error', onError) + channel.stderr.on('error', onError) + timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) +} + +// Why: exit 127 means powershell.exe wasn't found — the host isn't Windows, so surface the original POSIX failure instead. +function isPosixCommandNotFound(error: unknown): boolean { + return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT +} + +// Why: single-quote to block shell injection; ~ needs $HOME since single quotes suppress tilde 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, "'\\''")}'` +} + +function powerShellPathExpression(s: string): string { + if (s === '~') { + return '$HOME' + } + if (s.startsWith('~/') || s.startsWith('~\\')) { + return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}` + } + return powerShellLiteral(normalizeWindowsDrivePath(s)) +} + +// Why: renderer's POSIX path rebuild yields '/C:/…' or bare 'C:' (drive-relative), both mis-resolved by Set-Location; re-root to a proper drive path. +function normalizeWindowsDrivePath(s: string): string { + const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '') + return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped +} diff --git a/src/main/ssh/ssh-target-registry.ts b/src/main/ssh/ssh-target-registry.ts index aef003025cc..90636a99310 100644 --- a/src/main/ssh/ssh-target-registry.ts +++ b/src/main/ssh/ssh-target-registry.ts @@ -1,7 +1,13 @@ import type { SshConnectionStore } from './ssh-connection-store' import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import type { SshConnectionManager } from './ssh-connection-manager' -import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' +import type { + SshConnectionState, + SshTarget, + SshTargetAddResult, + SshTargetCreateInput, + SshTargetUpdateInput +} from '../../shared/ssh-types' /** * The SSH target/state registry, split out of `ipc/ssh.ts`. @@ -61,6 +67,26 @@ export function listRegisteredRemovedSshTargetLabels(): Record { return sshStore?.listRemovedTargetLabels() ?? {} } +/** Add/update/remove with the same rules the desktop's SSH settings use. */ +export type SshTargetMutations = { + add: (target: SshTargetCreateInput) => SshTargetAddResult + update: (targetId: string, updates: SshTargetUpdateInput) => SshTarget | null + remove: (targetId: string) => Promise +} + +let registeredSshTargetMutations: SshTargetMutations | null = null + +export function setSshTargetMutations(mutations: SshTargetMutations | null): void { + registeredSshTargetMutations = mutations +} + +export function getRegisteredSshTargetMutations(): SshTargetMutations { + if (!registeredSshTargetMutations) { + throw new Error('ssh_handlers_not_registered') + } + return registeredSshTargetMutations +} + let registeredGetActiveMultiplexer: | ((connectionId: string) => SshChannelMultiplexer | undefined) | null = null diff --git a/src/renderer/src/runtime/runtime-ssh-target-management.test.ts b/src/renderer/src/runtime/runtime-ssh-target-management.test.ts new file mode 100644 index 00000000000..7fc0e2dcc35 --- /dev/null +++ b/src/renderer/src/runtime/runtime-ssh-target-management.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from '../../../shared/ssh-target-management' + +const mocks = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + runtimeEnvironmentSupportsCapability: vi.fn() +})) + +vi.mock('./runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + runtimeEnvironmentSupportsCapability: mocks.runtimeEnvironmentSupportsCapability +})) + +const { + addRuntimeSshRepo, + addRuntimeSshTarget, + browseRuntimeSshDirectory, + listRuntimeEditableSshTargets, + removeRuntimeSshTarget, + RuntimeSshTargetManagementUnsupportedError, + updateRuntimeSshTarget +} = await import('./runtime-ssh-target-management') + +const input = { label: 'Build', host: 'build.internal', port: 22, username: 'ci' } + +const CALLS: [string, () => Promise][] = [ + ['list', () => listRuntimeEditableSshTargets('env-1')], + ['add', () => addRuntimeSshTarget('env-1', input)], + ['update', () => updateRuntimeSshTarget('env-1', 'ssh-1', input)], + ['remove', () => removeRuntimeSshTarget('env-1', 'ssh-1')], + ['browse', () => browseRuntimeSshDirectory('env-1', 'ssh-1', '~')], + ['add project', () => addRuntimeSshRepo('env-1', { connectionId: 'ssh-1', remotePath: '~/app' })] +] + +describe('managing a paired server SSH targets across versions', () => { + const sshApi = { addTarget: vi.fn(), listTargets: vi.fn(), browseDir: vi.fn() } + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', { api: { ssh: sshApi, repos: { addRemote: vi.fn() } } }) + }) + + it.each(CALLS)( + 'an old server without the capability refuses %s and nothing falls back to this client', + async (_name, call) => { + mocks.runtimeEnvironmentSupportsCapability.mockResolvedValue(false) + + await expect(call()).rejects.toBeInstanceOf(RuntimeSshTargetManagementUnsupportedError) + + expect(mocks.runtimeEnvironmentSupportsCapability).toHaveBeenCalledWith( + 'env-1', + SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY + ) + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(sshApi.addTarget).not.toHaveBeenCalled() + expect(sshApi.listTargets).not.toHaveBeenCalled() + expect(sshApi.browseDir).not.toHaveBeenCalled() + } + ) + + it.each(CALLS)('a current server is called for %s', async (_name, call) => { + mocks.runtimeEnvironmentSupportsCapability.mockResolvedValue(true) + mocks.callRuntimeRpc.mockResolvedValue({ targets: [], target: {}, repo: {} }) + + await call() + + expect(mocks.callRuntimeRpc.mock.calls[0]?.[0]).toEqual({ + kind: 'environment', + environmentId: 'env-1' + }) + }) + + it('sends a whole target so the server can clear omitted fields', async () => { + mocks.runtimeEnvironmentSupportsCapability.mockResolvedValue(true) + mocks.callRuntimeRpc.mockResolvedValue({ target: { id: 'ssh-1', ...input } }) + + await updateRuntimeSshTarget('env-1', 'ssh-1', input) + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'ssh.updateTarget', + { targetId: 'ssh-1', target: input }, + {} + ) + }) +}) diff --git a/src/renderer/src/runtime/runtime-ssh-target-management.ts b/src/renderer/src/runtime/runtime-ssh-target-management.ts new file mode 100644 index 00000000000..39fb3df97a7 --- /dev/null +++ b/src/renderer/src/runtime/runtime-ssh-target-management.ts @@ -0,0 +1,107 @@ +import type { Repo } from '../../../shared/repo-types' +import { + SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY, + type SshEditableTarget, + type SshEditableTargetAddResult, + type SshEditableTargetInput +} from '../../../shared/ssh-target-management' +import type { FilesystemPathFlavor } from '../../../shared/filesystem-entry-types' +import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' + +/** The paired server predates server SSH management; the caller shows "update the server". */ +export class RuntimeSshTargetManagementUnsupportedError extends Error { + constructor() { + super('Update this Orca server to manage its SSH hosts from here.') + this.name = 'RuntimeSshTargetManagementUnsupportedError' + } +} + +export function runtimeSupportsSshTargetManagement(environmentId: string): Promise { + return runtimeEnvironmentSupportsCapability( + environmentId, + SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY + ) +} + +// Why every call checks: an old server would answer method_not_found at best; there is never a +// local fallback, because this client's own SSH hosts are a different machine's. +async function callSshTargetManagement( + environmentId: string, + method: string, + params?: unknown, + timeoutMs?: number +): Promise { + if (!(await runtimeSupportsSshTargetManagement(environmentId))) { + throw new RuntimeSshTargetManagementUnsupportedError() + } + return callRuntimeRpc( + { kind: 'environment', environmentId }, + method, + params, + timeoutMs === undefined ? {} : { timeoutMs } + ) +} + +export async function listRuntimeEditableSshTargets( + environmentId: string +): Promise { + const result = await callSshTargetManagement<{ targets: SshEditableTarget[] }>( + environmentId, + 'ssh.listEditableTargets' + ) + return result.targets +} + +export function addRuntimeSshTarget( + environmentId: string, + target: SshEditableTargetInput +): Promise { + return callSshTargetManagement(environmentId, 'ssh.addTarget', { target }) +} + +export async function updateRuntimeSshTarget( + environmentId: string, + targetId: string, + target: SshEditableTargetInput +): Promise { + const result = await callSshTargetManagement<{ target: SshEditableTarget }>( + environmentId, + 'ssh.updateTarget', + { targetId, target } + ) + return result.target +} + +export async function removeRuntimeSshTarget( + environmentId: string, + targetId: string +): Promise { + await callSshTargetManagement(environmentId, 'ssh.removeTarget', { targetId }) +} + +export type RuntimeSshDirectoryListing = { + entries: { name: string; isDirectory: boolean }[] + resolvedPath: string + pathFlavor: FilesystemPathFlavor +} + +export function browseRuntimeSshDirectory( + environmentId: string, + targetId: string, + dirPath: string +): Promise { + // Why 20s: the server bounds its own SSH listing at 15s. + return callSshTargetManagement(environmentId, 'ssh.browseDir', { targetId, dirPath }, 20_000) +} + +export async function addRuntimeSshRepo( + environmentId: string, + args: { connectionId: string; remotePath: string; displayName?: string; kind?: 'git' | 'folder' } +): Promise { + const result = await callSshTargetManagement<{ repo: Repo }>( + environmentId, + 'repo.addRemote', + args + ) + return result.repo +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d9a580cdcf2..c87fc2d0988 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,4 +1,5 @@ import { WORKSPACE_ATTACHMENT_RUNTIME_CAPABILITIES } from './workspace-attachment-capabilities' +import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from './ssh-target-management' import { AUTOMATION_RUNTIME_CAPABILITIES, AUTOMATION_RUNTIME_CLIENT_CAPABILITIES @@ -44,18 +45,8 @@ import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resum import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability' import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from './agent-session-continue-interrupted-capability' import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities' -export { - AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY -} from './agent-session-resume-runtime-capabilities' +// Why a star: every resume capability is part of the runtime contract this module re-exports. +export * from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' import { AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES } from './agent-session-create-capabilities' @@ -447,6 +438,7 @@ export const RUNTIME_CAPABILITIES = [ PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY, RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY, + SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY, ...AGENT_LAUNCH_RUNTIME_CAPABILITIES ] as const diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 15be7257663..33463e3a7ad 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -491,7 +491,7 @@ import { DictationStart, SpeechModelAction } from './speech-params' -import { SshTarget } from './ssh-params' +import { RepoAddRemote, SshBrowseDir, SshTarget, SshTargetAdd, SshTargetUpdate } from './ssh-params' import { ContinueInterruptedParams } from './structured-agent-session-continue-params' import { AcknowledgeAttentionParams, @@ -1140,6 +1140,7 @@ export const RPC_PARAMS_BY_METHOD = { 'reference.find': ReferenceFind, 'reference.list': ReferenceList, 'repo.add': RepoPath, + 'repo.addRemote': RepoAddRemote, 'repo.baseRefDefault': RepoSelector, 'repo.clone': RepoClone, 'repo.create': RepoCreate, @@ -1203,11 +1204,16 @@ export const RPC_PARAMS_BY_METHOD = { 'speech.models.delete': SpeechModelAction, 'speech.models.download': SpeechModelAction, 'speech.models.list': null, + 'ssh.addTarget': SshTargetAdd, + 'ssh.browseDir': SshBrowseDir, 'ssh.connect': SshTarget, 'ssh.getState': SshTarget, + 'ssh.listEditableTargets': null, 'ssh.listRemovedTargetLabels': null, 'ssh.listTargetSummaries': null, 'ssh.listTargets': null, + 'ssh.removeTarget': SshTarget, + 'ssh.updateTarget': SshTargetUpdate, 'stats.summary': null, 'status.get': null, 'terminal.adoptOrphans': TerminalAdoptOrphans, diff --git a/src/shared/rpc-contract/ssh-params.ts b/src/shared/rpc-contract/ssh-params.ts index 77f336c991b..c64481b4599 100644 --- a/src/shared/rpc-contract/ssh-params.ts +++ b/src/shared/rpc-contract/ssh-params.ts @@ -1,5 +1,62 @@ import { z } from 'zod' +import { + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + SSH_REMOTE_RUNTIMES +} from '../ssh-types' +import { OptionalString, requiredString } from './rpc-param-primitives' export const SshTarget = z.object({ targetId: z.string().min(1) }) + +const boundedText = (max: number) => z.string().trim().min(1).max(max) + +// Why strict: an unknown key (a password, a passphrase, or a main-owned field such as a +// generation or managed-server fence) is refused rather than silently dropped, so a client can +// never believe the server kept a secret. Credentials are asked for when the server connects. +export const SshEditableTargetInput = z + .object({ + label: boundedText(200), + configHost: boundedText(512).optional(), + host: boundedText(512), + port: z.number().int().min(1).max(65_535), + username: z.string().trim().max(200), + identityFile: boundedText(4096).optional(), + identityAgent: boundedText(4096).optional(), + identitiesOnly: z.boolean().optional(), + gssapiAuthentication: z.boolean().optional(), + proxyCommand: boundedText(4096).optional(), + jumpHost: boundedText(1024).optional(), + relayGracePeriodSeconds: z + .number() + .int() + .min(0) + .max(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS) + .refine( + (value) => value === 0 || value >= MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + `Terminal timeout must be 0 or at least ${MIN_SSH_RELAY_GRACE_PERIOD_SECONDS} seconds` + ) + .optional(), + systemSshConnectionReuse: z.boolean().optional(), + remoteRuntime: z.enum(SSH_REMOTE_RUNTIMES).optional(), + allowRemoteCliControl: z.boolean().optional() + }) + .strict() + +export const SshTargetAdd = z.object({ target: SshEditableTargetInput }) + +/** Replaces the target's editable fields; an omitted optional field is cleared. */ +export const SshTargetUpdate = SshTarget.extend({ target: SshEditableTargetInput }) + +// Why untrimmed: a remote folder name may legitimately end in a space. +export const SshBrowseDir = SshTarget.extend({ + dirPath: z.string().min(1).max(4096) +}) + +export const RepoAddRemote = z.object({ + connectionId: requiredString('Missing SSH target'), + remotePath: requiredString('Missing remote path'), + displayName: OptionalString, + kind: z.enum(['git', 'folder']).optional() +}) diff --git a/src/shared/ssh-target-management.ts b/src/shared/ssh-target-management.ts new file mode 100644 index 00000000000..c61304705d3 --- /dev/null +++ b/src/shared/ssh-target-management.ts @@ -0,0 +1,46 @@ +import type { SshRepoReadoption, SshTarget, SshTargetSummary } from './ssh-types' + +// Why: hosts without this have no ssh.listEditableTargets/addTarget/updateTarget/removeTarget/ +// browseDir or repo.addRemote. A client must disable server SSH management, never manage its own +// SSH hosts in their place. +export const SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY = 'ssh.target-management.v1' as const + +/** + * The SSH target fields a paired client reads and writes on a server. There is deliberately no + * password or key passphrase: the server stores none and asks for them when it connects. + */ +export const SSH_EDITABLE_TARGET_FIELDS = [ + 'label', + 'configHost', + 'host', + 'port', + 'username', + 'identityFile', + 'identityAgent', + 'identitiesOnly', + 'gssapiAuthentication', + 'proxyCommand', + 'jumpHost', + 'relayGracePeriodSeconds', + 'systemSshConnectionReuse', + 'remoteRuntime', + 'allowRemoteCliControl' +] as const satisfies readonly (keyof SshTarget)[] + +export type SshEditableTargetField = (typeof SSH_EDITABLE_TARGET_FIELDS)[number] + +/** What a client sends to add a target or to replace an existing target's editable fields. */ +export type SshEditableTargetInput = Pick & + Partial>> + +export type SshEditableTarget = SshTargetSummary & + SshEditableTargetInput & { + source?: SshTarget['source'] + /** The host serves a managed Orca server; it can be edited but not removed from here. */ + managedServer?: true + } + +export type SshEditableTargetAddResult = { + target: SshEditableTarget + repoReadoptions: SshRepoReadoption[] +}