fix(ssh): link a moved project only to the server machine's own checkout, never a same-path project on another host (#29896)

This commit is contained in:
Neil
2026-10-16 17:39:15 -07:00
committed by GitHub
parent 99788a91fa
commit a2933244e3
8 changed files with 115 additions and 8 deletions
@@ -522,6 +522,23 @@ describe('orcad migration catalog persistence', () => {
expect(store.getFolderWorkspaces()).toEqual([])
})
it.each([
['a connection id', { connectionId: 'nested-target' }],
['an SSH execution host', { executionHostId: 'ssh:nested-target' as const }],
['a runtime execution host', { executionHostId: 'runtime:other-env' as const }]
])('imports beside a same-path project on another machine (%s)', (_case, host) => {
const store = createStore()
store.addRepo(makeRepo({ id: 'nested', path: REPOSITORY.path, ...host }))
commitCatalog(store, manifest())
expect(
store
.getRepos()
.map((repo) => repo.id)
.sort()
).toEqual(['nested', REPOSITORY.id].sort())
})
it('validates all id conflicts before adding any manifest row', () => {
const store = createStore()
store.addRepo(makeRepo({ id: REPOSITORY.id, path: '/srv/different' }))
@@ -7,6 +7,7 @@ import {
import type { ProjectGroup } from '../../../shared/project-group-types'
import type { Repo } from '../../../shared/repo-types'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import { isRepoOnCatalogHost } from '../../../shared/execution-host'
import { assertOrcadMigrationStagedCatalogClaims } from './orcad-staged-catalog-claims'
import {
toOrcadDestinationFolderWorkspace,
@@ -138,7 +139,8 @@ function assertNoRepositoryPathConflicts(incoming: Repo[], existing: Repo[]): vo
}
incomingByPath.set(key, repo.id)
}
for (const repo of existing) {
// Incoming rows land on this machine; a same-path row on another host is a different checkout.
for (const repo of existing.filter(isRepoOnCatalogHost)) {
if (
!incomingIdSet.has(repo.id) &&
incomingByPath.has(normalizeRuntimePathForComparison(repo.path))
@@ -32,6 +32,7 @@ vi.mock('../../shared/remote-runtime-client', () => ({
const {
abortRemoteOrcadMigrationCatalog,
commitRemoteOrcadMigrationCatalog,
listRemoteOrcadRepositories,
readRemoteOrcadMigrationCatalogState,
stageRemoteOrcadMigrationCatalog,
stageRemoteOrcadMigrationSnapshotChunk
@@ -83,6 +84,27 @@ describe('remote orcad migration catalog client', () => {
}
)
it('keeps each server project’s execution host, so a nested SSH row is never read as local', async () => {
sendRequest.mockResolvedValue({
ok: true,
result: {
repos: [
{ id: 'own', path: '/root/a', connectionId: null, displayName: 'own' },
{ id: 'nested', path: '/root/b', connectionId: 'ssh-nested' },
{ id: 'runtime', path: '/root/c', executionHostId: 'runtime:env' },
// An older orcad row: no host fields at all.
{ id: 'legacy', path: '/root/d' }
]
}
})
await expect(listRemoteOrcadRepositories(pairingCode)).resolves.toEqual([
{ id: 'own', path: '/root/a' },
{ id: 'nested', path: '/root/b', connectionId: 'ssh-nested' },
{ id: 'runtime', path: '/root/c', executionHostId: 'runtime:env' },
{ id: 'legacy', path: '/root/d' }
])
})
it('pins snapshot acknowledgments to the admitted destination runtime', async () => {
const request = {
migrationId: 'migration-1',
+20 -4
View File
@@ -30,6 +30,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import { hasExtraAgentArgs } from '../../shared/automation-extra-agent-args'
import { requiredOrcadMigrationSourceCapability } from '../../shared/orcad-migration-source-kind'
import type { OrcadServerRepository } from './orcad-migration-repository-link'
type OrcadCatalogMigrationOperation = 'abort' | 'commit' | 'stage' | 'state'
@@ -152,15 +153,23 @@ export async function stageRemoteOrcadMigrationBlobChunk(
return parseOrcadMigrationBlobChunkResult(response.result, request)
}
// Every orcad's repo.list returns stored rows whole, so a row without host fields is its own.
const ServerRepositoriesSchema = z.object({
repos: z.array(z.object({ id: z.string().min(1), path: z.string().min(1) }))
repos: z.array(
z.object({
id: z.string().min(1),
path: z.string().min(1),
connectionId: z.string().nullish(),
executionHostId: z.string().nullish()
})
)
})
/** The projects the destination already holds, as ids and host paths, through the same preflight. */
/** The projects the destination already holds, with the host each one's files are on. */
export async function listRemoteOrcadRepositories(
pairingCode: string,
options: CatalogRequestOptions = {}
): Promise<{ id: string; path: string }[]> {
): Promise<OrcadServerRepository[]> {
const pairing = parsePairingCode(pairingCode)
if (!pairing) {
throw new Error('orcad_migration_pairing_code_invalid')
@@ -169,7 +178,14 @@ export async function listRemoteOrcadRepositories(
if (!response.ok) {
throw new Error(`orcad_migration_repositories_failed:${response.error.message}`)
}
return ServerRepositoriesSchema.parse(response.result).repos.map(({ id, path }) => ({ id, path }))
return ServerRepositoriesSchema.parse(response.result).repos.map(
({ id, path, connectionId, executionHostId }) => ({
id,
path,
...(connectionId != null ? { connectionId } : {}),
...(executionHostId != null ? { executionHostId } : {})
})
)
}
async function requestCatalogState(
@@ -199,6 +199,21 @@ describe('moving onto a server another desktop already added the same project to
})
})
it.each([
['a connection id', { connectionId: 'nested-target' }],
['an SSH execution host', { executionHostId: 'ssh:nested-target' as const }],
['a runtime execution host', { executionHostId: 'runtime:other-env' as const }]
])(
'imports, not links, when the server’s same-path project lives on another machine (%s)',
async (_case, host) => {
// A project another host holds, reached through the server's own SSH target.
server.addRepo({ ...repo('nested-repo', '/root/migrepo', null), ...host })
await expect(convert()).resolves.toMatchObject({ outcome: 'converted' })
expect(serverRepoIds()).toEqual(['desktop-only', 'desktop-repo', 'nested-repo'])
expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.linkedRepositoryIds).toBeUndefined()
}
)
it('says nothing was linked when the server held none of the projects', async () => {
const result = await convert()
expect(result.outcome).toBe('converted')
@@ -10,6 +10,7 @@
import { getAutomationRunRepoId } from '../../shared/automation-run-identity'
import type { Automation } from '../../shared/automations-types'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { isRepoOnCatalogHost } from '../../shared/execution-host'
import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover'
import type { Repo } from '../../shared/repo-types'
import type { SshManagedServerLinkedProject } from '../../shared/ssh-types'
@@ -27,18 +28,22 @@ import { sshRelayMigrationCutoverPort } from './orcad-migration-ssh-relay-source
import { orcadCatalogFingerprint } from './orcad-retained-source'
import type { SshTargetOrcadClaims } from './ssh-target-orcad-claims'
export type OrcadServerRepository = Pick<Repo, 'id' | 'path'>
export type OrcadServerRepository = Pick<Repo, 'id' | 'path'> & {
connectionId?: string | null
executionHostId?: string | null
}
/**
* Incoming projects the server holds at the same host path. `sameId` is the server's copy of this
* desktop's own row, from an earlier move into the profile a stopped server left (#29560).
* desktop's own row, from an earlier move into the profile a stopped server left (#29560). Only the
* server machine's own rows count: one it reaches on another host is a different checkout.
*/
export function repositoriesHeldByServer(
incoming: readonly OrcadServerRepository[],
server: readonly OrcadServerRepository[]
): { otherId: string[]; sameId: string[] } {
const serverIdsByPath = new Map<string, Set<string>>()
for (const repo of server) {
for (const repo of server.filter(isRepoOnCatalogHost)) {
const key = normalizeRuntimePathForComparison(repo.path)
serverIdsByPath.set(key, (serverIdsByPath.get(key) ?? new Set()).add(repo.id))
}
+15
View File
@@ -10,6 +10,7 @@ import {
getSettingsFocusedExecutionHostId,
getSshTargetIdForExecutionHost,
getWorktreeExecutionHostId,
isRepoOnCatalogHost,
isUnresolvedOwnerHostId,
normalizeExecutionHostOrder,
normalizeExecutionHostScope,
@@ -203,3 +204,17 @@ describe('unresolved-owner sentinel', () => {
expect(isUnresolvedOwnerHostId('runtime:env-1')).toBe(false)
})
})
describe('isRepoOnCatalogHost', () => {
it('counts only rows whose files are on the catalog’s own machine', () => {
expect(isRepoOnCatalogHost({})).toBe(true)
expect(isRepoOnCatalogHost({ connectionId: null, executionHostId: 'local' })).toBe(true)
expect(isRepoOnCatalogHost({ connectionId: ' ' })).toBe(true)
expect(isRepoOnCatalogHost({ connectionId: 'ssh-1' })).toBe(false)
expect(isRepoOnCatalogHost({ executionHostId: 'ssh:ssh-1' })).toBe(false)
expect(isRepoOnCatalogHost({ executionHostId: 'runtime:env-1' })).toBe(false)
// A stale connection id contradicts `local`; an unknown host id is not proven local.
expect(isRepoOnCatalogHost({ executionHostId: 'local', connectionId: 'ssh-1' })).toBe(false)
expect(isRepoOnCatalogHost({ executionHostId: 'future:host' })).toBe(false)
})
})
+15
View File
@@ -206,6 +206,21 @@ export function getRepoExecutionHostId(repo: {
return getConnectionExecutionHostId(normalizeHostPart(repo.connectionId))
}
/**
* Whether a row's files are on the machine whose catalog holds it. Strict: an unknown host id or a
* stale `connectionId` beside `local` reads as elsewhere, so it never matches a local checkout.
*/
export function isRepoOnCatalogHost(repo: {
connectionId?: string | null
executionHostId?: string | null
}): boolean {
const executionHostId = normalizeHostPart(repo.executionHostId)
return (
(executionHostId === null || executionHostId === LOCAL_EXECUTION_HOST_ID) &&
normalizeHostPart(repo.connectionId) === null
)
}
export function getSshTargetIdForExecutionHost(
executionHostId: string | null | undefined
): string | null {