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 <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-10 18:25:54 -07:00
committed by GitHub
co-authored by m4air
parent f6d536dcda
commit e6af89741f
20 changed files with 1183 additions and 345 deletions
@@ -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
+3 -271
View File
@@ -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<RemoteBrowseResult> => {
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<RemoteBrowseResult> =>
browseSshDirectory(getConnectionManager(), args.targetId, args.dirPath)
)
}
type SshBrowseConnection = NonNullable<ReturnType<SshConnectionManager['getConnection']>>
function browseWithPosixShell(
conn: SshBrowseConnection,
dirPath: string
): Promise<RemoteBrowseResult> {
// 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<RemoteBrowseResult> {
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<RemoteBrowseResult> {
// 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<typeof setTimeout> | 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
}
@@ -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, {
+54 -43
View File
@@ -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<void> {
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)
+2
View File
@@ -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<void> {
setSshTargetRegistryStore(null)
setPersistedStore(null)
setSshTargetRegistryHandlers({ connect: null, getState: null })
setSshTargetMutations(null)
setCurrentGetMainWindow(() => null)
setCurrentRuntime(undefined)
}
+44
View File
@@ -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',
+11
View File
@@ -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',
@@ -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<string>([
...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 })
}
)
})
})
+113 -14
View File
@@ -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<SshEditableTargetField, unknown>
}
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)
})
]
@@ -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()
@@ -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<Repo> {
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<ProjectHostSetupResult> {
assertCloneHostIsSupported(args.hostId)
const knownRepoIds = new Set(this.deps.listRepos().map((repo) => repo.id))
@@ -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),
+276
View File
@@ -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<RemoteBrowseResult> {
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<ReturnType<SshConnectionManager['getConnection']>>
function browseWithPosixShell(
conn: SshBrowseConnection,
dirPath: string
): Promise<RemoteBrowseResult> {
// 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<RemoteBrowseResult> {
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<RemoteBrowseResult> {
// 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<typeof setTimeout> | 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
}
+27 -1
View File
@@ -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<string, string> {
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<void>
}
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
@@ -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<unknown>][] = [
['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 },
{}
)
})
})
@@ -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<boolean> {
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<T>(
environmentId: string,
method: string,
params?: unknown,
timeoutMs?: number
): Promise<T> {
if (!(await runtimeSupportsSshTargetManagement(environmentId))) {
throw new RuntimeSshTargetManagementUnsupportedError()
}
return callRuntimeRpc<T>(
{ kind: 'environment', environmentId },
method,
params,
timeoutMs === undefined ? {} : { timeoutMs }
)
}
export async function listRuntimeEditableSshTargets(
environmentId: string
): Promise<SshEditableTarget[]> {
const result = await callSshTargetManagement<{ targets: SshEditableTarget[] }>(
environmentId,
'ssh.listEditableTargets'
)
return result.targets
}
export function addRuntimeSshTarget(
environmentId: string,
target: SshEditableTargetInput
): Promise<SshEditableTargetAddResult> {
return callSshTargetManagement(environmentId, 'ssh.addTarget', { target })
}
export async function updateRuntimeSshTarget(
environmentId: string,
targetId: string,
target: SshEditableTargetInput
): Promise<SshEditableTarget> {
const result = await callSshTargetManagement<{ target: SshEditableTarget }>(
environmentId,
'ssh.updateTarget',
{ targetId, target }
)
return result.target
}
export async function removeRuntimeSshTarget(
environmentId: string,
targetId: string
): Promise<void> {
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<RuntimeSshDirectoryListing> {
// 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<Repo> {
const result = await callSshTargetManagement<{ repo: Repo }>(
environmentId,
'repo.addRemote',
args
)
return result.repo
}
+4 -12
View File
@@ -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
+7 -1
View File
@@ -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,
+57
View File
@@ -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()
})
+46
View File
@@ -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<SshTarget, 'label' | 'host' | 'port' | 'username'> &
Partial<Pick<SshTarget, Exclude<SshEditableTargetField, 'label' | 'host' | 'port' | 'username'>>>
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[]
}