mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
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:
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
Reference in New Issue
Block a user