mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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 =
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user