Files
orca/src/main/runtime/runtime-project-host-setup-controller.ts
T
NeilandMohith S 92fd257bd9 fix(projects): refuse project setup on a paired server's SSH host with a clear reason (#33284)
* Refuse unsupported SSH project setup before local API dispatch

* fix(projects): keep the desktop's SSH refusal copy and drop a re-export

The desktop's own project-setup IPC is not an Orca server, so it keeps
saying the SSH connection is not connected; the renderer now refuses SSH
setup before reaching it. Callers import the shared refusal message
directly instead of through the controller. Adds a headless paired e2e
that sets up a project on the server's SSH host from a web client.

Refs #33124

* fix(projects): give the server refusal only for a paired server's SSH host

A desktop's own SSH host keeps its 'not connected' copy; the server
refusal shows on a web client, or for a target only a paired server lists.

Refs #33124

* test(e2e): read the added SSH target from the RPC result

* test(e2e): pass a typed SSH execution host id

* test(e2e): give the paired SSH project-setup timeout bump its reason

---------

Co-authored-by: Mohith S <smohith58@gmail.com>
2026-10-25 13:15:44 -07:00

325 lines
12 KiB
TypeScript

import type {
Project,
ProjectHostSetup,
ProjectHostSetupCloneArgs,
ProjectHostSetupCreateArgs,
ProjectHostSetupCreateResult,
ProjectHostSetupDeleteArgs,
ProjectHostSetupDeleteResult,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
ProjectHostSetupUpdateArgs,
ProjectHostSetupUpdateResult,
ProjectUpdateArgs
} from '../../shared/project-types'
import type { Repo } from '../../shared/repo-types'
import {
getRepoExecutionHostId,
getSshTargetIdForExecutionHost,
parseExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup'
import { resolveImportedFolderProjectLink } from '../../shared/imported-folder-project-identity'
import { invalidateAuthorizedRootsCache } from '../ipc/registered-worktree-roots-cache'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import { updateProjectHostSetupWithProvenKind } from '../folder-repo-git-conversion'
import type { RuntimeStore } from './runtime-store-contract'
import { readAllWorktreeMetaForRepo } from '../persistence/host-qualified-worktree-meta'
import { readCurrentGitRemoteIdentity } from '../repo-git-remote-identity'
import {
countRepoWorkspacesWithMetadata,
describeProjectRemovalRefusal
} from './project-removal-impact'
import {
assertWslMoveHostSetupIdWritable,
assertWslMoveHostSetupWritable,
assertWslMoveReposWritable
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
import { runtimeVisibleProjectHostSetups } from './runtime-visible-catalog'
import {
findProjectHostSetup,
SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE,
type ProjectHostSetupTarget
} from '../../shared/project-host-setup-target'
type RuntimeProjectHostSetupDependencies = {
getStore: () => RuntimeStore | null
listRepos: () => Repo[]
addRepo: (path: string, kind: 'folder' | 'git', hostId: ExecutionHostId) => Promise<Repo>
cloneRepo: (url: string, destination: string, hostId: ExecutionHostId) => Promise<Repo>
invalidateResolvedWorktrees: () => void
invalidateWorktreeScan: (repoId: string) => void
notifyReposChanged: () => void
countLiveTerminalsForRepo: (repo: Repo) => number
}
// cloneRepo runs locally; accepting SSH would register the client's checkout as a remote repo.
function assertCloneHostIsSupported(hostId: ExecutionHostId | null | undefined): void {
if (parseExecutionHostId(hostId)?.kind !== 'ssh') {
return
}
throw new Error(
'Cloning onto an SSH host is not supported. Clone the repository on the host, then set the project up from that existing folder.'
)
}
function sshTargetNotServedError(connectionId: string): SshHostNotServedError {
return new SshHostNotServedError(connectionId, SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE)
}
export class RuntimeProjectHostSetupController {
constructor(private readonly deps: RuntimeProjectHostSetupDependencies) {}
listProjects(): Project[] {
return this.deps.getStore()?.getProjects?.() ?? []
}
updateProject(projectId: string, updates: ProjectUpdateArgs['updates']): Project {
const store = this.deps.getStore()
if (!store?.updateProject) {
throw new Error('runtime_unavailable')
}
const existing = store.getProjects?.().find((entry) => entry.id === projectId)
assertWslMoveReposWritable(store, existing?.sourceRepoIds ?? [])
const project = store.updateProject(projectId, updates)
if (!project) {
throw new Error(`Project not found: ${projectId}`)
}
this.deps.invalidateResolvedWorktrees()
this.deps.notifyReposChanged()
return project
}
listSetups(): ProjectHostSetup[] {
return this.deps.getStore()?.getProjectHostSetups?.() ?? []
}
/** What clients may list; internal lookups keep the full store, as `listSetups` does. */
listVisibleSetups(): ProjectHostSetup[] {
return runtimeVisibleProjectHostSetups(this.deps.getStore())
}
createSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult {
const store = this.deps.getStore()
if (!store?.createProjectHostSetup) {
throw new Error('runtime_unavailable')
}
assertWslMoveHostSetupWritable(store, args)
const result = store.createProjectHostSetup(args)
if (!result) {
throw new Error(`Project not found: ${args.projectId}`)
}
this.deps.notifyReposChanged()
return result
}
async setupExistingFolder(
args: ProjectHostSetupExistingFolderArgs
): Promise<ProjectHostSetupResult> {
if (!this.deps.getStore()) {
throw new Error('runtime_unavailable')
}
const kind = args.kind === 'folder' ? 'folder' : 'git'
const knownRepoIds = new Set(this.deps.listRepos().map((repo) => repo.id))
const sshTargetId = getSshTargetIdForExecutionHost(args.hostId)
if (sshTargetId) {
throw sshTargetNotServedError(sshTargetId)
}
const repo = await this.deps.addRepo(args.path, kind, args.hostId)
const repoWasCreated = !knownRepoIds.has(repo.id)
// Stored remotes may predate user-added ones; probe current Git state.
const folderRemote =
repoWasCreated && repo.gitRemoteIdentity !== undefined
? repo
: await readCurrentGitRemoteIdentity(repo)
return this.completeSetup(args, repo, repoWasCreated, folderRemote)
}
/** Keep repo.addRemote registered for older clients, but refuse unserved SSH targets with guidance for adding them on the right host. */
addSshRepo(args: { connectionId: string }): Promise<Repo> {
if (!this.deps.getStore()) {
throw new Error('runtime_unavailable')
}
throw new SshHostNotServedError(args.connectionId, SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE)
}
async setupClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> {
assertCloneHostIsSupported(args.hostId)
const knownRepoIds = new Set(this.deps.listRepos().map((repo) => repo.id))
const repo = await this.deps.cloneRepo(args.url, args.destination, args.hostId)
return this.completeSetup(
{ ...args, path: repo.path, kind: 'git', setupMethod: 'cloned' },
repo,
!knownRepoIds.has(repo.id),
// Fresh-clone remote is unprobed; Git-remote linking needs that fact.
repo.gitRemoteIdentity === undefined ? await readCurrentGitRemoteIdentity(repo) : repo
)
}
// Refuse unavailable/WSL-moving projects before any Git probe.
updateSetup(args: ProjectHostSetupUpdateArgs): Promise<ProjectHostSetupUpdateResult> {
const store = this.deps.getStore()
const updateProjectHostSetup = store?.updateProjectHostSetup?.bind(store)
if (!store || !updateProjectHostSetup) {
throw new Error('runtime_unavailable')
}
assertWslMoveHostSetupIdWritable(store, args)
return this.finishSetupUpdate(store, updateProjectHostSetup, args)
}
private async finishSetupUpdate(
store: RuntimeStore,
updateProjectHostSetup: NonNullable<RuntimeStore['updateProjectHostSetup']>,
args: ProjectHostSetupUpdateArgs
): Promise<ProjectHostSetupUpdateResult> {
const result = await updateProjectHostSetupWithProvenKind(
store,
(setupArgs) => {
assertWslMoveHostSetupIdWritable(store, setupArgs)
return updateProjectHostSetup(setupArgs)
},
args,
true
)
if (!result) {
throw new Error(`Project host setup not found: ${args.setupId}`)
}
if ('worktreeBasePath' in args.updates && result.repo) {
void prepareLocalWorktreeRootForRepo(store, result.repo)
invalidateAuthorizedRootsCache()
}
if (result.repo && ('path' in args.updates || 'kind' in args.updates)) {
this.retireRepoCatalog(result.repo.id)
}
this.deps.invalidateResolvedWorktrees()
this.deps.notifyReposChanged()
return result
}
deleteSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult {
const store = this.deps.getStore()
if (!store?.deleteProjectHostSetup) {
throw new Error('runtime_unavailable')
}
assertWslMoveHostSetupIdWritable(store, args)
if (args.force === false) {
this.assertSetupRemovalIsUnused(store, args)
}
const result = store.deleteProjectHostSetup(args)
if (!result) {
throw new Error(`Project host setup not found: ${args.setupId}`)
}
if (result.repo) {
this.retireRepoCatalog(result.repo.id)
}
this.deps.invalidateResolvedWorktrees()
this.deps.notifyReposChanged()
return result
}
// Invalidate shared repo-id scan caches on desktop and runtime, regardless of path/kind.
private retireRepoCatalog(repoId: string): void {
runWorktreeChangeInvalidators(repoId)
invalidateAuthorizedRootsCache()
}
// Only explicit force:false adds refusal; omission preserves older clients' delete behavior.
private assertSetupRemovalIsUnused(store: RuntimeStore, target: ProjectHostSetupTarget): void {
const setup = findProjectHostSetup(this.listSetups(), target)
// Measure the exact host row the store will delete, never its same-id sibling.
const repo = setup?.repoId
? this.deps
.listRepos()
.find(
(entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId
)
: undefined
if (!repo) {
return
}
const refusal = describeProjectRemovalRefusal({
liveTerminals: this.deps.countLiveTerminalsForRepo(repo),
workspacesWithMetadata: countRepoWorkspacesWithMetadata(
repo,
// Read host-qualified: sibling same-path rows may exist only in canonical maps.
readAllWorktreeMetaForRepo(store, repo)
)
})
if (refusal) {
throw new Error(refusal)
}
}
private completeSetup(
args: ProjectHostSetupExistingFolderArgs,
initialRepo: Repo,
repoWasCreated: boolean,
folderRemote: Pick<Repo, 'gitRemoteIdentity'>
): ProjectHostSetupResult {
try {
return this.linkRepo(args, initialRepo, folderRemote)
} catch (error) {
if (repoWasCreated) {
// Rollback must remove only this host's row, never a same-id sibling.
this.deps.getStore()?.removeProjectForHost?.(initialRepo)
this.deps.invalidateResolvedWorktrees()
this.deps.invalidateWorktreeScan(initialRepo.id)
invalidateAuthorizedRootsCache()
this.deps.notifyReposChanged()
}
throw error
}
}
private linkRepo(
args: ProjectHostSetupExistingFolderArgs,
initialRepo: Repo,
folderRemote: Pick<Repo, 'gitRemoteIdentity'>
): ProjectHostSetupResult {
const store = this.deps.getStore()
if (!store) {
throw new Error('runtime_unavailable')
}
assertWslMoveHostSetupWritable(store, {
projectId: args.projectId,
hostId: getRepoExecutionHostId(initialRepo),
repoId: initialRepo.id
})
let repo = initialRepo
let setup = getProjectHostSetupForRepo(this.listSetups(), repo)
if (setup.projectId !== args.projectId) {
const existingProject = this.listProjects().find((project) => project.id === args.projectId)
const updated = store.updateRepo(
repo,
resolveImportedFolderProjectLink(
folderRemote,
args.projectId,
existingProject?.providerIdentity ?? args.projectProviderIdentity
)
)
if (!updated) {
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
}
repo = updated
setup = getProjectHostSetupForRepo(this.listSetups(), repo)
}
const setupMethod = args.setupMethod ?? 'imported-existing-folder'
const updated = store.updateRepo(repo, { projectHostSetupMethod: setupMethod })
if (!updated) {
throw new Error(
`Project setup repo disappeared before setup metadata could be linked: ${repo.id}`
)
}
repo = updated
setup = getProjectHostSetupForRepo(this.listSetups(), repo)
const project = this.listProjects().find((entry) => entry.id === setup.projectId)
if (!project) {
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
}
return { project, setup, repo }
}
}