From 92fd257bd96f8ee05f14cd2d7fbf95b2cf067a28 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:15:44 -0700 Subject: [PATCH] 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 --- config/scripts/pr-e2e-source-routing.mjs | 11 ++ .../repository-project-operations.spec.ts | 5 +- ...time-project-host-setup-controller.test.ts | 16 ++- .../runtime-project-host-setup-controller.ts | 11 +- .../store/projects/project-host-routing.ts | 45 +++++- .../projects/project-host-setup-actions.ts | 10 +- src/renderer/src/store/slices/repos.test.ts | 130 +++++++++++------- src/shared/project-host-setup-target.ts | 3 + ...less-paired-ssh-host-project-setup.spec.ts | 65 +++++++++ 9 files changed, 219 insertions(+), 77 deletions(-) create mode 100644 tests/e2e/headless-paired-ssh-host-project-setup.spec.ts diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index deb355faa9c..1b093d32ff2 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -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'], diff --git a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts index e4a36dc0a87..dbea0644e52 100644 --- a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts +++ b/src/main/runtime/orca-runtime-tests/repository-project-operations.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() diff --git a/src/main/runtime/runtime-project-host-setup-controller.test.ts b/src/main/runtime/runtime-project-host-setup-controller.test.ts index 7e03490f037..a22c9c11eaf 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.test.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.test.ts @@ -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() }) diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index f7ea576285f..1b428e143e5 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -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 { diff --git a/src/renderer/src/store/projects/project-host-routing.ts b/src/renderer/src/store/projects/project-host-routing.ts index 5639315bde9..30262ffb8af 100644 --- a/src/renderer/src/store/projects/project-host-routing.ts +++ b/src/renderer/src/store/projects/project-host-routing.ts @@ -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, + 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, + 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 { if (target.kind !== 'environment') { return diff --git a/src/renderer/src/store/projects/project-host-setup-actions.ts b/src/renderer/src/store/projects/project-host-setup-actions.ts index 6561d808bbf..c45fcffb616 100644 --- a/src/renderer/src/store/projects/project-host-setup-actions.ts +++ b/src/renderer/src/store/projects/project-host-setup-actions.ts @@ -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 = diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index b4866ecea99..9cf3fb9917a 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -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', diff --git a/src/shared/project-host-setup-target.ts b/src/shared/project-host-setup-target.ts index c3950712a4c..379f40c26ea 100644 --- a/src/shared/project-host-setup-target.ts +++ b/src/shared/project-host-setup-target.ts @@ -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 diff --git a/tests/e2e/headless-paired-ssh-host-project-setup.spec.ts b/tests/e2e/headless-paired-ssh-host-project-setup.spec.ts new file mode 100644 index 00000000000..35fff5597d8 --- /dev/null +++ b/tests/e2e/headless-paired-ssh-host-project-setup.spec.ts @@ -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() + } +})