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>
This commit is contained in:
Neil
2026-10-25 13:15:44 -07:00
committed by GitHub
co-authored by Mohith S
parent 06a3f88c35
commit 92fd257bd9
9 changed files with 219 additions and 77 deletions
+11
View File
@@ -94,6 +94,17 @@ export const PR_E2E_SOURCE_ROUTES = [
file === 'src/renderer/src/components/right-sidebar/useFileExplorerTree.ts' ||
file === 'src/renderer/src/components/right-sidebar/use-file-explorer-tree-load-effects.ts')
},
{
// A paired client refuses project setup on its server's SSH host with a clear reason (#33124).
id: 'paired.ssh-host-project-setup',
specs: ['tests/e2e/headless-paired-ssh-host-project-setup.spec.ts'],
matches: (file) =>
file === 'tests/e2e/helpers/headless-paired-runtime-host.ts' ||
(isProductSource(file) &&
/^src\/(?:renderer\/src\/store\/projects\/project-host-setup-actions|main\/runtime\/runtime-project-host-setup-controller|shared\/project-host-setup-target)\.ts$/.test(
file
))
},
{
id: 'serve.orcad-mode-switch',
specs: ['tests/e2e/orcad-serve-mode-switch.spec.ts'],
@@ -642,9 +642,10 @@ describe('OrcaRuntimeService', () => {
expect(cloneError).toMatchObject({
message: expect.stringMatching(/Cloning onto an SSH host is not supported/)
})
// Remote folder registration is supported, so refusal must name the missing connection.
expect(existingFolderError).toMatchObject({
message: expect.stringMatching(/SSH connection "openclaw" not found or not connected/)
code: 'ssh_host_not_served',
connectionId: 'openclaw',
message: expect.stringContaining("This Orca server can't add projects on its SSH hosts.")
})
} finally {
spawnSpy.mockRestore()
@@ -1,8 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import {
RuntimeProjectHostSetupController,
SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE
} from './runtime-project-host-setup-controller'
import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller'
import { SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE } from '../../shared/project-host-setup-target'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup'
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
import type { Repo } from '../../shared/repo-types'
@@ -63,11 +61,11 @@ describe('RuntimeProjectHostSetupController host routing', () => {
})
await expect(setup).rejects.toThrow(SshHostNotServedError)
await expect(setup).rejects.toThrow(`SSH connection "${TARGET_ID}" not found or not connected`)
await expect(setup).rejects.toThrow(SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE)
expect(addRepo).not.toHaveBeenCalled()
})
it('names the decoded connection id of a percent-encoded SSH target', async () => {
it('preserves the decoded connection id when refusing a percent-encoded SSH target', async () => {
const { controller, addRepo, projectId } = makeController()
await expect(
@@ -77,7 +75,11 @@ describe('RuntimeProjectHostSetupController host routing', () => {
path: REPO_PATH,
kind: 'folder'
})
).rejects.toThrow('SSH connection "my host" not found or not connected')
).rejects.toMatchObject({
code: 'ssh_host_not_served',
connectionId: 'my host',
message: SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE
})
expect(addRepo).not.toHaveBeenCalled()
})
@@ -22,10 +22,7 @@ import {
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,
sshTargetNotConnectedMessage
} from '../providers/ssh-host-not-served-error'
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'
@@ -44,6 +41,7 @@ import {
import { runtimeVisibleProjectHostSetups } from './runtime-visible-catalog'
import {
findProjectHostSetup,
SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE,
type ProjectHostSetupTarget
} from '../../shared/project-host-setup-target'
@@ -68,11 +66,8 @@ function assertCloneHostIsSupported(hostId: ExecutionHostId | null | undefined):
)
}
export const SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE =
"This Orca server can't add projects on its SSH hosts. Add the SSH host on your computer to open its projects."
function sshTargetNotServedError(connectionId: string): SshHostNotServedError {
return new SshHostNotServedError(connectionId, sshTargetNotConnectedMessage(connectionId))
return new SshHostNotServedError(connectionId, SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE)
}
export class RuntimeProjectHostSetupController {
@@ -12,14 +12,19 @@ import {
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import {
assertRuntimeEnvironmentCapability,
callRuntimeRpc
} from '../../runtime/runtime-rpc-client'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import { adoptEndpointProjectCatalog } from '../../../../shared/endpoint-catalog-adoption'
import type { ProjectHostSetupTarget } from '../../../../shared/project-host-setup-target'
import {
SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE,
type ProjectHostSetupTarget
} from '../../../../shared/project-host-setup-target'
import { isWebClientLocation } from '@/lib/web-client-location'
import type { AppState } from '../types'
export function getProjectSetupRuntimeTarget(
hostId: ProjectHostSetupExistingFolderArgs['hostId']
@@ -30,6 +35,42 @@ export function getProjectSetupRuntimeTarget(
: { kind: 'local' }
}
// Why before dispatch: no process sets up `ssh:` hosts (managed ones are `runtime:`), and a
// paired server's SSH host would otherwise reach this client's own API.
export function assertNotSshProjectHost(
state: Pick<AppState, 'sshTargetLabels' | 'sshStateByEnvironment'>,
hostId: ExecutionHostId
): void {
const parsed = parseExecutionHostId(hostId)
if (parsed?.kind !== 'ssh') {
return
}
throw new Error(
isPairedServerSshTarget(state, parsed.targetId)
? SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE
: `SSH connection "${parsed.targetId}" not found or not connected`
)
}
/**
* SSH hosts a paired server owns: the server reaches their runtime over its own tunnel, which this
* client has no route to (#29593). A web client only ever sees its server's hosts.
*/
function isPairedServerSshTarget(
state: Pick<AppState, 'sshTargetLabels' | 'sshStateByEnvironment'>,
targetId: string
): boolean {
if (isWebClientLocation()) {
return true
}
if (state.sshTargetLabels.has(targetId)) {
return false
}
return [...state.sshStateByEnvironment.values()].some((bucket) =>
bucket.targetLabels.has(targetId)
)
}
async function assertProjectHostSetupRuntimeCapability(target: RuntimeClientTarget): Promise<void> {
if (target.kind !== 'environment') {
return
@@ -12,12 +12,13 @@ import { omitSparsePresetsForRepos } from '../slices/sparse-presets'
import { repoMatchesHostIdentity } from '../slices/repo-host-identity'
import { callRuntimeRpc } from '../../runtime/runtime-rpc-client'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import type { RepoSlice } from '../repos/repo-state'
import { ERROR_TOAST_DURATION } from '../repos/repo-state'
import { adoptFromEndpoint } from '../adopt-from-endpoint'
import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization'
import {
assertNotSshProjectHost,
assertProjectHostSetupMutationRuntimeCapabilities,
getProjectSetupRuntimeTarget,
isSameProjectHostSetup,
@@ -39,6 +40,7 @@ export function createProjectHostSetupActions(
return {
setupProjectExistingFolder: async (args) => {
try {
assertNotSshProjectHost(get(), args.hostId)
const target = getProjectSetupRuntimeTarget(args.hostId)
await assertProjectHostSetupMutationRuntimeCapabilities(target)
const projectProviderIdentity =
@@ -254,11 +256,7 @@ export function createProjectHostSetupActions(
setupProjectClone: async (args) => {
try {
const parsedHost = parseExecutionHostId(args.hostId)
// Why: an SSH host clones only through its paired server; never fall back to this computer.
if (parsedHost?.kind === 'ssh') {
throw new Error(`SSH connection "${parsedHost.targetId}" not found or not connected`)
}
assertNotSshProjectHost(get(), args.hostId)
const target = getProjectSetupRuntimeTarget(args.hostId)
await assertProjectHostSetupMutationRuntimeCapabilities(target)
const repo =
+78 -52
View File
@@ -5,6 +5,8 @@ import { workItemsCacheKey } from '../github/cache-identity'
import type { Project, ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import { toast } from 'sonner'
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
import { createFallbackProxy } from '../../web/preload-api/web-fallback-api'
import {
installReposRuntimeRoutingHarness,
localRepo,
@@ -305,6 +307,82 @@ describe('repo slice runtime routing', () => {
})
})
const SERVER_SSH_REFUSAL =
"This Orca server can't add projects on its SSH hosts. Add the SSH host on your computer to open its projects."
it.each([
['folder', null],
['git', null],
['folder', 'env-1'],
['git', 'env-1']
] as const)(
'refuses SSH %s setup on a web client with focused runtime %s before calling a local API',
async (kind, activeRuntimeEnvironmentId) => {
const store = createTestStore()
store.setState({ settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId }) })
Object.assign(window, { __ORCA_WEB_CLIENT__: true })
window.api.projects = createFallbackProxy(['projects'])
const before = store.getState()
await expect(
store.getState().setupProjectExistingFolder({
projectId: 'project-1',
hostId: 'ssh:ssh-1',
path: '/srv/project',
kind
})
).resolves.toBeNull()
expect(projectsSetupExistingFolder).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(store.getState().repos).toBe(before.repos)
expect(store.getState().projects).toBe(before.projects)
expect(store.getState().projectHostSetups).toBe(before.projectHostSetups)
expect(toast.error).toHaveBeenCalledWith('Failed to add project', {
description: SERVER_SSH_REFUSAL,
duration: expect.any(Number)
})
}
)
it.each([
["a paired server's", 'server', SERVER_SSH_REFUSAL],
["this desktop's own", 'desktop', 'SSH connection "ssh-1" not found or not connected']
] as const)('names why %s SSH host cannot take a project', async (_case, owner, message) => {
const store = createTestStore()
const labels = new Map([['ssh-1', 'Recipe VM']])
store.setState({
sshTargetLabels: owner === 'desktop' ? labels : new Map(),
sshStateByEnvironment: new Map([
[
'env-1',
{
connectionStates: new Map(),
targetLabels: owner === 'server' ? labels : new Map(),
targetGenerations: new Map(),
removedTargetLabels: new Map(),
targetsHydrated: true
}
]
])
})
await expect(
store.getState().setupProjectExistingFolder({
projectId: 'project-1',
hostId: 'ssh:ssh-1',
path: '/srv/project',
kind: 'git'
})
).resolves.toBeNull()
expect(projectsSetupExistingFolder).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('Failed to add project', {
description: message,
duration: expect.any(Number)
})
})
it('sets up a project on the active runtime host through runtime RPC', async () => {
const project: Project = {
id: 'project-1',
@@ -367,58 +445,6 @@ describe('repo slice runtime routing', () => {
})
})
it('sets up an SSH host through local IPC even when a runtime is focused', async () => {
const project: Project = {
id: 'project-1',
displayName: 'Project',
badgeColor: '#000',
sourceRepoIds: ['ssh-repo'],
createdAt: 1,
updatedAt: 1
}
const setup: ProjectHostSetup = {
id: 'ssh-repo',
projectId: project.id,
hostId: 'ssh:openclaw%202',
repoId: 'ssh-repo',
path: '/srv/project',
displayName: 'Remote',
connectionId: 'openclaw 2',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
}
projectsSetupExistingFolder.mockResolvedValue({
project,
setup,
repo: { ...remoteRepo, connectionId: 'openclaw 2' }
})
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
await expect(
store.getState().setupProjectExistingFolder({
projectId: project.id,
hostId: 'ssh:openclaw%202',
path: '/srv/project',
kind: 'git'
})
).resolves.toEqual({
project,
setup,
repo: { ...remoteRepo, connectionId: 'openclaw 2', executionHostId: 'ssh:openclaw%202' }
})
expect(projectsSetupExistingFolder).toHaveBeenCalledWith({
projectId: project.id,
hostId: 'ssh:openclaw%202',
path: '/srv/project',
kind: 'git'
})
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('clones a project locally before aligning it as a host setup', async () => {
const project: Project = {
id: 'project-1',
+3
View File
@@ -1,5 +1,8 @@
import type { ExecutionHostId } from './execution-host'
export const SERVER_SSH_PROJECT_ADD_UNAVAILABLE_MESSAGE =
"This Orca server can't add projects on its SSH hosts. Add the SSH host on your computer to open its projects."
/** Repo IDs can repeat across hosts; hostId selects the intended setup, while older clients omit it and retain first-ID matching. */
export type ProjectHostSetupTarget = {
setupId: string
@@ -0,0 +1,65 @@
/**
* A paired client cannot set up a project on an SSH host its headless server owns: the server
* reaches that host's runtime over its own tunnel, which the client has no route to. Adding or
* cloning there says so plainly instead of failing with a TypeError or "not connected" (#33124).
*/
import { expect, test } from './helpers/orca-app'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import { launchPairedWebClient } from './helpers/paired-electron-client'
import { toSshExecutionHostId } from '../../src/shared/execution-host'
const REFUSAL = "This Orca server can't add projects on its SSH hosts."
test('a paired client refuses project setup on its server’s SSH host with a clear reason', async () => {
// Boots a headless server and a paired Electron client before the refusal is observable.
test.setTimeout(180_000)
const host = await launchHeadlessPairedRuntimeHost()
try {
const sshTarget = await host.client.call<{ target: { id: string } }>('ssh.addTarget', {
target: { label: 'Recipe VM', host: '127.0.0.1', port: 1, username: 'dev' }
})
const client = await launchPairedWebClient(host.app, host.offer, { waitForWorkspace: false })
try {
await expect
.poll(() => client.page.evaluate(() => Boolean(window.__store)), { timeout: 30_000 })
.toBe(true)
const errors = await client.page.evaluate(async (hostId) => {
const messages: string[] = []
const original = console.error
console.error = (...args: unknown[]) => {
messages.push(args.map(String).join(' '))
original(...args)
}
try {
const state = window.__store!.getState()
const added = await state.setupProjectExistingFolder({
projectId: 'project-on-b',
hostId,
path: '/home/dev/repo',
kind: 'git'
})
const cloned = await state.setupProjectClone({
projectId: 'project-on-b',
hostId,
url: 'https://example.com/repo.git',
destination: '/home/dev'
})
return { added, cloned, messages }
} finally {
console.error = original
}
}, toSshExecutionHostId(sshTarget.result.target.id))
expect(errors.added).toBeNull()
expect(errors.cloned).toBeNull()
expect(errors.messages).toHaveLength(2)
for (const message of errors.messages) {
expect(message).toContain(REFUSAL)
}
} finally {
await client.dispose()
}
} finally {
await host.dispose()
}
})