diff --git a/src/main/ipc/repos/local-repo-registration.ts b/src/main/ipc/repos/local-repo-registration.ts index 4cdaa169597..5fcb816ba98 100644 --- a/src/main/ipc/repos/local-repo-registration.ts +++ b/src/main/ipc/repos/local-repo-registration.ts @@ -11,6 +11,7 @@ import { getLinkedWorktreeMainRepoRoot, getRepoName } from '../../git/repo' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' @@ -73,7 +74,11 @@ export async function addLocalRepoFromPath( } } - const detected = await detectRepoIconAndUpstream({ repoPath: resolvedPath, kind: repoKind }) + const detected = await detectRepoIconAndUpstream({ + repoPath: resolvedPath, + kind: repoKind, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) const repo: Repo = { id: randomUUID(), path: resolvedPath, diff --git a/src/main/ipc/repos/nested-repo-import-handler.ts b/src/main/ipc/repos/nested-repo-import-handler.ts index 4f18a9fbbf5..90294ec464a 100644 --- a/src/main/ipc/repos/nested-repo-import-handler.ts +++ b/src/main/ipc/repos/nested-repo-import-handler.ts @@ -14,6 +14,7 @@ import { } from '../../project-groups/nested-repo-import' import { createNestedRepoImportTargetResolver } from '../../project-groups/nested-repo-import-target' import { getSshGitProvider } from '../../providers/ssh-git-dispatch' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../shared/execution-host' import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' import { getActiveMultiplexer } from '../ssh' @@ -122,7 +123,9 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store const detected = await detectRepoIconAndUpstream({ repoPath: importRepoPath, kind: 'git', - connectionId: args.connectionId + executionHostId: args.connectionId + ? toSshExecutionHostId(args.connectionId) + : LOCAL_EXECUTION_HOST_ID }) const repo: Repo = { id: randomUUID(), diff --git a/src/main/ipc/repos/remote-repo-registration.ts b/src/main/ipc/repos/remote-repo-registration.ts index 35ab72056ae..ff92f7d0b1b 100644 --- a/src/main/ipc/repos/remote-repo-registration.ts +++ b/src/main/ipc/repos/remote-repo-registration.ts @@ -84,7 +84,7 @@ export async function addRemoteRepoFromPath( const detected = await detectRepoIconAndUpstream({ repoPath: resolvedPath, kind: repoKind, - connectionId: args.connectionId + executionHostId: toSshExecutionHostId(args.connectionId) }) const repo: Repo = { id: randomUUID(), diff --git a/src/main/ipc/repos/repo-clone-lifecycle.ts b/src/main/ipc/repos/repo-clone-lifecycle.ts index 920c231aa7b..b6531fdd2b3 100644 --- a/src/main/ipc/repos/repo-clone-lifecycle.ts +++ b/src/main/ipc/repos/repo-clone-lifecycle.ts @@ -17,6 +17,7 @@ import { deriveValidatedClonePath, getClonePathComparisonKey } from '../../git/repo-clone-path' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache' @@ -257,7 +258,11 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor return existing } - const detected = await detectRepoIconAndUpstream({ repoPath: clonePath, kind: 'git' }) + const detected = await detectRepoIconAndUpstream({ + repoPath: clonePath, + kind: 'git', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) const repo: Repo = { id: randomUUID(), path: clonePath, diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index a2054450155..90894894253 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -264,7 +264,11 @@ export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: S return { repo: raceWinner } } - const detected = await detectRepoIconAndUpstream({ repoPath: targetPath, kind: repoKind }) + const detected = await detectRepoIconAndUpstream({ + repoPath: targetPath, + kind: repoKind, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) const repo: Repo = { id: randomUUID(), path: targetPath, diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index 23b7c6a47e9..a52d110aa02 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -103,7 +103,7 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { expect(repo.gitRemoteIdentity).toBeUndefined() expect(probeGitRemoteIdentity).toHaveBeenCalledWith( '/workspace/sample-app', - undefined, + 'local', expect.objectContaining({ signal: expect.any(AbortSignal) }) ) @@ -113,6 +113,60 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { expect(onChanged).toHaveBeenCalledTimes(1) }) + it('probes an SSH row that carries only executionHostId on its own host', async () => { + // Why: a row minted with the unified spelling has no `connectionId`, and reading the raw field + // would run `git remote -v` against a same-named path on this machine (#11163). + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) + const store = makeStore(makeRepo({ executionHostId: 'ssh:builder' })) + + enrichMissingRepoGitRemoteIdentities(store) + + expect(probeGitRemoteIdentity).toHaveBeenCalledWith( + '/workspace/sample-app', + 'ssh:builder', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + await flushRepoGitRemoteIdentityEnrichmentForTests() + }) + + it('never hands a runtime row nested SSH target to this client dispatch table', async () => { + // Why: `connectionId` on a `runtime:` row names a target inside that server's namespace, so + // dialing it here reaches a same-named box of ours. The row keeps the probe this process has + // always run for it; what it must never do is dial our same-named target. + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'unavailable' }) + const store = makeStore( + makeRepo({ connectionId: 'nested-1', executionHostId: 'runtime:env-a' }) + ) + + enrichMissingRepoGitRemoteIdentities(store) + + expect(probeGitRemoteIdentity).toHaveBeenCalledWith( + '/workspace/sample-app', + 'local', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + await flushRepoGitRemoteIdentityEnrichmentForTests() + }) + + it('keeps same-path rows on two different SSH hosts from sharing one backoff', async () => { + // Why: the location key decides coalescing and backoff. Keyed on the raw field, two rows that + // carry only `executionHostId` collapse onto one key, so the first host being down suppresses + // the probe for the second one entirely. + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'unavailable' }) + const store = makeStore( + makeRepo({ id: 'repo-m4air', executionHostId: 'ssh:m4air' }), + makeRepo({ id: 'repo-openclaw', executionHostId: 'ssh:openclaw' }) + ) + + await sweep(store) + + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(2) + expect(vi.mocked(probeGitRemoteIdentity).mock.calls.map((call) => call[1])).toEqual([ + 'ssh:m4air', + 'ssh:openclaw' + ]) + }) + it('coalesces concurrent probes for the same repo location', async () => { const probe = deferred() vi.mocked(probeGitRemoteIdentity).mockReturnValue(probe.promise) diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts index 1f4ce458beb..a97961fe59f 100644 --- a/src/main/repo-git-remote-identity-enrichment.ts +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -1,3 +1,9 @@ +import { + getRepoExecutionHostId, + getSshTargetIdForExecutionHost, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../shared/execution-host' import type { Repo } from '../shared/repo-types' import { probeGitRemoteIdentity } from './repo-git-remote-identity' @@ -39,8 +45,20 @@ const pendingChangeListeners = new Set<() => void>() let sweepInFlight: Promise | null = null let rerunRequested = false -function getRepoLocationKey(repo: Pick): string { - return `${repo.connectionId ?? 'local'}\0${repo.path}` +// Keyed on the resolved host, not the raw field: two rows at the same path on different hosts are +// different locations, and collapsing them shares one probe (and one abort) across both. +function getRepoLocationKey(repo: Pick): string { + return `${getRepoExecutionHostId(repo)}\0${repo.path}` +} + +/** + * The host *this* process may run the probe on. `getSshTargetIdForExecutionHost`, not the row's + * file-holding target: a `runtime:` row's nested SSH target lives in that server's namespace, so + * dialing it here would reach a same-named box of ours — a wrong-host answer, not a local one. + */ +function getRepoProbeHostId(repo: Repo): ExecutionHostId { + const hostId = getRepoExecutionHostId(repo) + return getSshTargetIdForExecutionHost(hostId) ? hostId : LOCAL_EXECUTION_HOST_ID } function getCurrentRepo(store: RepoIdentityStore, id: string): Repo | undefined { @@ -52,7 +70,7 @@ function isSameProbedRepo(snapshot: Repo, current: Repo | undefined): current is !!current && current.kind !== 'folder' && current.path === snapshot.path && - (current.connectionId ?? null) === (snapshot.connectionId ?? null) + getRepoExecutionHostId(current) === getRepoExecutionHostId(snapshot) ) } @@ -101,7 +119,7 @@ async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo) : NO_IDENTITY_RETRY_TTL_MS const controller = new AbortController() const promise = (async () => { - const result = await probeGitRemoteIdentity(repo.path, repo.connectionId, { + const result = await probeGitRemoteIdentity(repo.path, getRepoProbeHostId(repo), { signal: controller.signal }) // Why the signal and not a catch: probeGitRemoteIdentity swallows the AbortError and RESOLVES diff --git a/src/main/repo-git-remote-identity.test.ts b/src/main/repo-git-remote-identity.test.ts index ee5bdc9eb01..b513d835c43 100644 --- a/src/main/repo-git-remote-identity.test.ts +++ b/src/main/repo-git-remote-identity.test.ts @@ -1,56 +1,112 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { gitExecFileAsync } from './git/runner' -import { getSshGitProvider } from './providers/ssh-git-dispatch' +import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch' import { probeGitRemoteIdentity } from './repo-git-remote-identity' vi.mock('./git/runner', () => ({ gitExecFileAsync: vi.fn() })) -vi.mock('./providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) const gitlabRemote = 'origin\tgit@gitlab.example.com:team/orca.git (fetch)\n' +const gitlabIdentity = { + canonicalKey: 'gitlab.example.com/team/orca', + remoteName: 'origin', + remoteUrl: 'git@gitlab.example.com:team/orca.git' +} + +const registered: string[] = [] + +function registerHost(connectionId: string, stdout = gitlabRemote) { + const exec = vi.fn().mockResolvedValue({ stdout, stderr: '' }) + registerSshGitProvider(connectionId, { exec } as never) + registered.push(connectionId) + return exec +} beforeEach(() => { vi.clearAllMocks() }) +afterEach(() => { + for (const connectionId of registered.splice(0)) { + unregisterSshGitProvider(connectionId) + } +}) + describe('probeGitRemoteIdentity', () => { it('resolves the canonical identity for a non-GitHub remote', async () => { vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: gitlabRemote, stderr: '' }) - await expect(probeGitRemoteIdentity('/repos/orca')).resolves.toEqual({ + await expect(probeGitRemoteIdentity('/repos/orca', 'local')).resolves.toEqual({ status: 'resolved', - identity: { - canonicalKey: 'gitlab.example.com/team/orca', - remoteName: 'origin', - remoteUrl: 'git@gitlab.example.com:team/orca.git' - } + identity: gitlabIdentity }) }) it('settles on no-remote when git answers with nothing usable', async () => { vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: '', stderr: '' }) - await expect(probeGitRemoteIdentity('/repos/orca')).resolves.toEqual({ status: 'no-remote' }) + await expect(probeGitRemoteIdentity('/repos/orca', 'local')).resolves.toEqual({ + status: 'no-remote' + }) + }) + + it('routes each SSH host to its own git provider', async () => { + const m4air = registerHost('m4air') + const openclaw = registerHost( + 'openclaw', + 'origin\tgit@gitlab.example.com:team/other.git (fetch)\n' + ) + + await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:m4air')).resolves.toEqual({ + status: 'resolved', + identity: gitlabIdentity + }) + await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:openclaw')).resolves.toEqual({ + status: 'resolved', + identity: { + canonicalKey: 'gitlab.example.com/team/other', + remoteName: 'origin', + remoteUrl: 'git@gitlab.example.com:team/other.git' + } + }) + expect(m4air).toHaveBeenCalledTimes(1) + expect(openclaw).toHaveBeenCalledTimes(1) + expect(gitExecFileAsync).not.toHaveBeenCalled() }) it('reports unavailable when the SSH host has no connected git provider', async () => { - vi.mocked(getSshGitProvider).mockReturnValue(undefined) - - await expect(probeGitRemoteIdentity('/repos/orca', 'builder')).resolves.toEqual({ + await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({ status: 'unavailable' }) + expect(gitExecFileAsync).not.toHaveBeenCalled() + }) + + // A runtime host's Git is executed by that environment's own server, and the SSH target on its + // repo row lives in that server's namespace. Dialing a same-named target here answers for + // another machine's repository. + it('refuses a runtime host even when its nested SSH target is registered on this client', async () => { + const nested = registerHost('nested-1') + + await expect(probeGitRemoteIdentity('/repos/orca', 'runtime:env-a')).resolves.toEqual({ + status: 'unavailable' + }) + expect(nested).not.toHaveBeenCalled() + expect(gitExecFileAsync).not.toHaveBeenCalled() }) it('reports unavailable when the local git command fails', async () => { vi.mocked(gitExecFileAsync).mockRejectedValue(new Error('not a git repository')) - await expect(probeGitRemoteIdentity('/repos/orca')).resolves.toEqual({ status: 'unavailable' }) + await expect(probeGitRemoteIdentity('/repos/orca', 'local')).resolves.toEqual({ + status: 'unavailable' + }) }) it('reports unavailable when a connected SSH provider cannot reach the host', async () => { const exec = vi.fn().mockRejectedValue(new Error('ssh: connect to host builder: down')) - vi.mocked(getSshGitProvider).mockReturnValue({ exec } as never) + registerSshGitProvider('builder', { exec } as never) + registered.push('builder') - await expect(probeGitRemoteIdentity('/repos/orca', 'builder')).resolves.toEqual({ + await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({ status: 'unavailable' }) expect(exec).toHaveBeenCalledWith( @@ -62,11 +118,9 @@ describe('probeGitRemoteIdentity', () => { }) it('settles on no-remote for an SSH repo git answered for with no remotes', async () => { - vi.mocked(getSshGitProvider).mockReturnValue({ - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - } as never) + registerHost('builder', '') - await expect(probeGitRemoteIdentity('/repos/orca', 'builder')).resolves.toEqual({ + await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({ status: 'no-remote' }) }) @@ -75,7 +129,7 @@ describe('probeGitRemoteIdentity', () => { vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: gitlabRemote, stderr: '' }) const controller = new AbortController() - await probeGitRemoteIdentity('/repos/orca', null, { signal: controller.signal }) + await probeGitRemoteIdentity('/repos/orca', 'local', { signal: controller.signal }) expect(gitExecFileAsync).toHaveBeenCalledWith( ['remote', '-v'], @@ -90,11 +144,10 @@ describe('probeGitRemoteIdentity', () => { }) it('bounds the SSH probe under the relay request timeout and forwards the caller signal', async () => { - const exec = vi.fn().mockResolvedValue({ stdout: gitlabRemote, stderr: '' }) - vi.mocked(getSshGitProvider).mockReturnValue({ exec } as never) + const exec = registerHost('builder') const controller = new AbortController() - await probeGitRemoteIdentity('/repos/orca', 'builder', { signal: controller.signal }) + await probeGitRemoteIdentity('/repos/orca', 'ssh:builder', { signal: controller.signal }) expect(exec).toHaveBeenCalledWith( ['remote', '-v'], @@ -108,7 +161,9 @@ describe('probeGitRemoteIdentity', () => { it('maps a timed-out local probe to unavailable, never no-remote', async () => { vi.mocked(gitExecFileAsync).mockRejectedValue(new Error('git timed out.')) - await expect(probeGitRemoteIdentity('/repos/orca')).resolves.toEqual({ status: 'unavailable' }) + await expect(probeGitRemoteIdentity('/repos/orca', 'local')).resolves.toEqual({ + status: 'unavailable' + }) }) it('maps an aborted probe to unavailable, never no-remote', async () => { @@ -119,7 +174,7 @@ describe('probeGitRemoteIdentity', () => { controller.abort() await expect( - probeGitRemoteIdentity('/repos/orca', null, { signal: controller.signal }) + probeGitRemoteIdentity('/repos/orca', 'local', { signal: controller.signal }) ).resolves.toEqual({ status: 'unavailable' }) }) }) diff --git a/src/main/repo-git-remote-identity.ts b/src/main/repo-git-remote-identity.ts index 65b2c88e5a5..7badf6370a9 100644 --- a/src/main/repo-git-remote-identity.ts +++ b/src/main/repo-git-remote-identity.ts @@ -1,6 +1,7 @@ +import type { ExecutionHostId } from '../shared/execution-host' import { deriveGitRemoteIdentity, type GitRemoteIdentity } from '../shared/git-remote-identity' import { gitExecFileAsync } from './git/runner' -import { getSshGitProvider } from './providers/ssh-git-dispatch' +import { resolveGitRouteForHost } from './providers/execution-host-provider-dispatch' // Why: the runner only arms its kill timer when a timeout is passed, so an unbounded local probe // never settles on a hung NFS/SMB cwd (the path walk blocks) or a wedged `wsl.exe -d `. @@ -26,20 +27,29 @@ export type GitRemoteIdentityProbe = export async function probeGitRemoteIdentity( repoPath: string, - connectionId?: string | null, + executionHostId: ExecutionHostId, options: GitRemoteIdentityProbeOptions = {} ): Promise { try { - const result = connectionId - ? await getSshGitProvider(connectionId)?.exec(['remote', '-v'], repoPath, { - signal: options.signal, - timeoutMs: options.timeoutMs ?? SSH_PROBE_TIMEOUT_MS - }) - : await gitExecFileAsync(['remote', '-v'], { - cwd: repoPath, - timeout: options.timeoutMs ?? LOCAL_PROBE_TIMEOUT_MS, - signal: options.signal - }) + // Inside the try on purpose: an id naming no host must land on `unavailable` like every other + // probe that never reached git. It must never become the local answer for a remote path. + const route = resolveGitRouteForHost(executionHostId) + if (route.kind === 'runtime') { + // That environment's server runs its own git, and the SSH target on its repo row is nested in + // that server's namespace — dialing it here answers for a same-named box of ours. + return { status: 'unavailable' } + } + const result = + route.kind === 'ssh' + ? await route.provider?.exec(['remote', '-v'], repoPath, { + signal: options.signal, + timeoutMs: options.timeoutMs ?? SSH_PROBE_TIMEOUT_MS + }) + : await gitExecFileAsync(['remote', '-v'], { + cwd: repoPath, + timeout: options.timeoutMs ?? LOCAL_PROBE_TIMEOUT_MS, + signal: options.signal + }) if (!result) { return { status: 'unavailable' } } @@ -54,9 +64,9 @@ export async function probeGitRemoteIdentity( export async function detectGitRemoteIdentity( repoPath: string, - connectionId?: string | null, + executionHostId: ExecutionHostId, options: GitRemoteIdentityProbeOptions = {} ): Promise { - const probe = await probeGitRemoteIdentity(repoPath, connectionId, options) + const probe = await probeGitRemoteIdentity(repoPath, executionHostId, options) return probe.status === 'resolved' ? probe.identity : null } diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index f016650490e..9457ef998c9 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -1,8 +1,13 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { gitExecFileAsync } from './git/runner' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from './providers/ssh-filesystem-dispatch' +import type { IFilesystemProvider } from './providers/types' import { detectRepoIcon, detectRepoIconAndUpstream } from './repo-icon-autodetect' const PNG_1X1_BASE64 = @@ -16,7 +21,30 @@ async function makeTempRepoDir(): Promise { return dir } +const registeredHosts: string[] = [] + +/** A remote host whose only readable file is a package.json naming a host-specific homepage. */ +function registerHomepageHost(connectionId: string, homepage: string) { + const stat = vi.fn(async (filePath: string) => { + if (!filePath.endsWith('/package.json')) { + throw new Error('ENOENT') + } + return { type: 'file', size: 64, mtime: 0 } + }) + const readFile = vi.fn(async () => ({ + content: JSON.stringify({ homepage }), + isBinary: false, + mimeType: 'application/json' + })) + registerSshFilesystemProvider(connectionId, { stat, readFile } as unknown as IFilesystemProvider) + registeredHosts.push(connectionId) + return { stat, readFile } +} + afterEach(async () => { + for (const connectionId of registeredHosts.splice(0)) { + unregisterSshFilesystemProvider(connectionId) + } await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) @@ -29,7 +57,9 @@ describe('detectRepoIcon', () => { JSON.stringify({ homepage: 'https://example.com' }) ) - await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'local' }) + ).resolves.toEqual({ type: 'image', src: `data:image/png;base64,${PNG_1X1_BASE64}`, source: 'file', @@ -45,7 +75,9 @@ describe('detectRepoIcon', () => { Buffer.from(PNG_1X1_BASE64, 'base64') ) - await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'local' }) + ).resolves.toEqual({ type: 'image', src: `data:image/png;base64,${PNG_1X1_BASE64}`, source: 'file', @@ -59,7 +91,9 @@ describe('detectRepoIcon', () => { await mkdir(join(repoPath, 'public'), { recursive: true }) await writeFile(join(repoPath, 'public', 'icon.webp'), Buffer.from(webpBase64, 'base64')) - await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'local' }) + ).resolves.toEqual({ type: 'image', src: `data:image/webp;base64,${webpBase64}`, source: 'file', @@ -74,7 +108,9 @@ describe('detectRepoIcon', () => { JSON.stringify({ homepage: 'https://app.example.com/docs' }) ) - await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'local' }) + ).resolves.toEqual({ type: 'image', src: 'https://www.google.com/s2/favicons?domain=app.example.com&sz=64', source: 'favicon', @@ -91,7 +127,9 @@ describe('detectRepoIcon', () => { Buffer.from(PNG_1X1_BASE64, 'base64') ) - await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'local' }) + ).resolves.toEqual({ type: 'image', src: `data:image/png;base64,${PNG_1X1_BASE64}`, source: 'file', @@ -111,7 +149,9 @@ describe('detectRepoIcon', () => { Buffer.from(PNG_1X1_BASE64, 'base64') ) - await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'local' }) + ).resolves.toEqual({ type: 'image', src: `data:image/png;base64,${PNG_1X1_BASE64}`, source: 'file', @@ -131,7 +171,9 @@ describe('detectRepoIcon', () => { Buffer.from(PNG_1X1_BASE64, 'base64') ) - await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toBeUndefined() + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'local' }) + ).resolves.toBeUndefined() }) it('does not resolve declared icon hrefs outside the repo', async () => { @@ -141,7 +183,9 @@ describe('detectRepoIcon', () => { await writeFile(join(parentPath, 'outside.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) await writeFile(join(repoPath, 'index.html'), '') - await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toBeUndefined() + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'local' }) + ).resolves.toBeUndefined() }) it('returns no icon for an SSH-hosted repo whose filesystem provider is missing', async () => { @@ -155,19 +199,54 @@ describe('detectRepoIcon', () => { ) await expect( - detectRepoIcon({ repoPath, kind: 'folder', connectionId: 'ssh-target-not-connected' }) + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'ssh:not-connected' }) ).resolves.toBeUndefined() }) - it('still detects local icons when the repo has no connection', async () => { + it('still detects local icons for a repo on this machine', async () => { const repoPath = await makeTempRepoDir() await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) await expect( - detectRepoIcon({ repoPath, kind: 'folder', connectionId: null }) + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'local' }) ).resolves.toMatchObject({ source: 'file', label: 'favicon.png' }) }) + it('routes each SSH host to its own filesystem provider', async () => { + const repoPath = await makeTempRepoDir() + // On disk on this machine, so a host-blind probe would answer with this one for both hosts. + await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) + registerHomepageHost('m4air', 'https://m4air.example.com') + registerHomepageHost('openclaw', 'https://openclaw.example.com') + + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'ssh:m4air' }) + ).resolves.toMatchObject({ + source: 'favicon', + src: expect.stringContaining('m4air.example.com') + }) + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'ssh:openclaw' }) + ).resolves.toMatchObject({ + source: 'favicon', + src: expect.stringContaining('openclaw.example.com') + }) + }) + + it('reads nothing for a runtime host even when its nested SSH target is registered here', async () => { + // Why: a `runtime:` repo row's `connectionId` names a target in that server's namespace. Both + // spellings of the incumbent shape are wrong here — a null one reads this machine's copy of + // the path, and the nested id dials a same-named box of ours. + const repoPath = await makeTempRepoDir() + await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) + const nested = registerHomepageHost('nested-1', 'https://nested.example.com') + + await expect( + detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'runtime:env-a' }) + ).resolves.toBeUndefined() + expect(nested.stat).not.toHaveBeenCalled() + }) + it('falls back to the GitHub owner avatar for GitHub repos', async () => { const repoPath = await makeTempRepoDir() await gitExecFileAsync(['init'], { cwd: repoPath }) @@ -175,7 +254,9 @@ describe('detectRepoIcon', () => { cwd: repoPath }) - await expect(detectRepoIcon({ repoPath, kind: 'git' })).resolves.toEqual({ + await expect( + detectRepoIcon({ repoPath, kind: 'git', executionHostId: 'local' }) + ).resolves.toEqual({ type: 'image', src: 'https://github.com/stablyai.png?size=64', source: 'github', @@ -194,7 +275,9 @@ describe('detectRepoIcon', () => { cwd: repoPath }) - await expect(detectRepoIcon({ repoPath, kind: 'git' })).resolves.toEqual({ + await expect( + detectRepoIcon({ repoPath, kind: 'git', executionHostId: 'local' }) + ).resolves.toEqual({ type: 'image', src: 'https://github.com/stablyai.png?size=64', source: 'github', @@ -206,7 +289,9 @@ describe('detectRepoIcon', () => { const repoPath = await makeTempRepoDir() await gitExecFileAsync(['init'], { cwd: repoPath }) - await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({ + await expect( + detectRepoIconAndUpstream({ repoPath, kind: 'git', executionHostId: 'local' }) + ).resolves.toEqual({ upstream: null }) }) @@ -221,7 +306,9 @@ describe('detectRepoIcon', () => { cwd: repoPath }) - await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({ + await expect( + detectRepoIconAndUpstream({ repoPath, kind: 'git', executionHostId: 'local' }) + ).resolves.toEqual({ gitRemoteIdentity: { canonicalKey: 'github.com/stablyai/orca', remoteName: 'upstream', @@ -251,7 +338,9 @@ describe('detectRepoIcon', () => { } ) - await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({ + await expect( + detectRepoIconAndUpstream({ repoPath, kind: 'git', executionHostId: 'local' }) + ).resolves.toEqual({ gitRemoteIdentity: { canonicalKey: 'github.com/upstream-org/rocket', remoteName: 'upstream', @@ -276,7 +365,9 @@ describe('detectRepoIcon', () => { { cwd: repoPath } ) - await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toMatchObject({ + await expect( + detectRepoIconAndUpstream({ repoPath, kind: 'git', executionHostId: 'local' }) + ).resolves.toMatchObject({ gitRemoteIdentity: { canonicalKey: 'git.company.test/platform/tools/sample-app', remoteName: 'origin', diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 202fd907dce..542e28fb8b2 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -1,4 +1,5 @@ import { readFile, stat } from 'node:fs/promises' +import type { ExecutionHostId } from '../shared/execution-host' import type { GitHubRepositoryIdentity } from '../shared/github/pull-request-types' import type { RepoKind } from '../shared/repo-types' import { @@ -8,7 +9,10 @@ import { type RepoIcon } from '../shared/repo-icon' import { getRepoSlug, getRepoUpstream } from './github/client' -import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' +import { + resolveFilesystemRouteForHost, + resolveGitRouteForHost +} from './providers/execution-host-provider-dispatch' import type { IFilesystemProvider } from './providers/types' import { detectGitRemoteIdentity } from './repo-git-remote-identity' import { detectRepoFileIcon } from './repo-icon-file-detection' @@ -77,13 +81,36 @@ async function detectRemotePackageHomepageIcon( } } +/** + * The connection this client may dial to read `executionHostId`'s remotes, or `refuse` when it may + * dial none. `runtime:` is refused rather than degraded to `null`: that server runs its own git, + * and answering "no connection" would read this machine's copy of the path instead. + */ +function repoRemoteReadConnection( + executionHostId: ExecutionHostId +): { kind: 'refuse' } | { kind: 'dial'; connectionId: string | null } { + const route = resolveGitRouteForHost(executionHostId) + switch (route.kind) { + case 'local': + return { kind: 'dial', connectionId: null } + case 'ssh': + return { kind: 'dial', connectionId: route.connectionId } + case 'runtime': + return { kind: 'refuse' } + } +} + export async function detectGitHubAvatarIcon( repoPath: string, - connectionId?: string | null, + executionHostId: ExecutionHostId, upstream?: GitHubRepositoryIdentity | null ): Promise { try { - const slug = githubAvatarSlug(await getRepoSlug(repoPath, connectionId), upstream) + const target = repoRemoteReadConnection(executionHostId) + if (target.kind === 'refuse') { + return null + } + const slug = githubAvatarSlug(await getRepoSlug(repoPath, target.connectionId), upstream) return slug ? githubAvatarIcon(slug) : null } catch { return null @@ -93,34 +120,35 @@ export async function detectGitHubAvatarIcon( export async function detectRepoIcon({ repoPath, kind, - connectionId, + executionHostId, upstream }: { repoPath: string kind: RepoKind - connectionId?: string | null + executionHostId: ExecutionHostId upstream?: GitHubRepositoryIdentity | null }): Promise { try { - const fsProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - // Why: a remote repoPath with no provider must not be probed on the client - // filesystem — a same-named local path answers for the wrong repository. - if (fsProvider || !connectionId) { - const fileIcon = await detectRepoFileIcon(repoPath, { connectionId, fsProvider }) - if (fileIcon) { - return fileIcon - } + const route = resolveFilesystemRouteForHost(executionHostId) + const fileIcon = await detectRepoFileIcon(repoPath, route) + if (fileIcon) { + return fileIcon + } - const homepageIcon = fsProvider - ? await detectRemotePackageHomepageIcon(repoPath, fsProvider) - : await detectLocalPackageHomepageIcon(repoPath) - if (homepageIcon) { - return homepageIcon - } + // Why the same route again: a remote repoPath with no provider, and every runtime host, must + // not be probed on the client filesystem — a same-named local path answers for the wrong repo. + const remoteProvider = route.kind === 'ssh' ? route.provider : null + const homepageIcon = remoteProvider + ? await detectRemotePackageHomepageIcon(repoPath, remoteProvider) + : route.kind === 'local' + ? await detectLocalPackageHomepageIcon(repoPath) + : null + if (homepageIcon) { + return homepageIcon } if (kind === 'git') { - return (await detectGitHubAvatarIcon(repoPath, connectionId, upstream)) ?? undefined + return (await detectGitHubAvatarIcon(repoPath, executionHostId, upstream)) ?? undefined } } catch { // Repo creation must not fail because a best-effort icon probe failed. @@ -133,16 +161,20 @@ export async function detectRepoIcon({ export async function detectRepoIconAndUpstream({ repoPath, kind, - connectionId + executionHostId }: { repoPath: string kind: RepoKind - connectionId?: string | null + executionHostId: ExecutionHostId }) { - const upstream = kind === 'git' ? await getRepoUpstream(repoPath, connectionId) : null + const remoteRead = repoRemoteReadConnection(executionHostId) + const upstream = + kind === 'git' && remoteRead.kind === 'dial' + ? await getRepoUpstream(repoPath, remoteRead.connectionId) + : null const gitRemoteIdentity = - kind === 'git' ? await detectGitRemoteIdentity(repoPath, connectionId) : null - const repoIcon = await detectRepoIcon({ repoPath, kind, connectionId, upstream }) + kind === 'git' ? await detectGitRemoteIdentity(repoPath, executionHostId) : null + const repoIcon = await detectRepoIcon({ repoPath, kind, executionHostId, upstream }) return { ...(repoIcon ? { repoIcon } : {}), ...(gitRemoteIdentity ? { gitRemoteIdentity } : {}), diff --git a/src/main/repo-icon-file-detection.test.ts b/src/main/repo-icon-file-detection.test.ts index e2238b72412..820b2edddfc 100644 --- a/src/main/repo-icon-file-detection.test.ts +++ b/src/main/repo-icon-file-detection.test.ts @@ -1,9 +1,17 @@ import { readFile, stat } from 'node:fs/promises' import type * as FsPromisesModule from 'node:fs/promises' import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostFilesystemRoute } from './providers/execution-host-provider-dispatch' import type { FileReadResult, FileStat, IFilesystemProvider } from './providers/types' import { detectRepoFileIcon } from './repo-icon-file-detection' +function sshRoute( + connectionId: string, + provider: IFilesystemProvider | null +): ExecutionHostFilesystemRoute { + return { kind: 'ssh', hostId: `ssh:${connectionId}`, connectionId, provider } +} + // Why: the boundary assertion is "no local read happened", which needs the real // fs entrypoints spied rather than stubbed. vi.mock('node:fs/promises', async (importOriginal) => { @@ -37,7 +45,7 @@ describe('detectRepoFileIcon remote probing', () => { readFile: async () => ({ content: WEBP_BASE64, isBinary: true, mimeType: 'image/webp' }) }) - await expect(detectRepoFileIcon('/repo', { fsProvider: provider })).resolves.toEqual({ + await expect(detectRepoFileIcon('/repo', sshRoute('m4air', provider))).resolves.toEqual({ type: 'image', src: `data:image/webp;base64,${WEBP_BASE64}`, source: 'file', @@ -61,7 +69,7 @@ describe('detectRepoFileIcon remote probing', () => { } }) - await expect(detectRepoFileIcon('/repo', { fsProvider: provider })).resolves.toMatchObject({ + await expect(detectRepoFileIcon('/repo', sshRoute('m4air', provider))).resolves.toMatchObject({ source: 'file', label: 'favicon.png' }) @@ -84,7 +92,7 @@ describe('detectRepoFileIcon remote probing', () => { } }) - await expect(detectRepoFileIcon('/repo', { fsProvider: provider })).resolves.toBeNull() + await expect(detectRepoFileIcon('/repo', sshRoute('m4air', provider))).resolves.toBeNull() expect(maxActiveStats).toBeGreaterThan(1) expect(maxActiveStats).toBeLessThanOrEqual(6) }) @@ -95,18 +103,35 @@ describe('detectRepoFileIcon connection boundary', () => { vi.mocked(stat).mockClear() vi.mocked(readFile).mockClear() + await expect(detectRepoFileIcon('/repo', sshRoute('ssh-target-1', null))).resolves.toBeNull() + + expect(stat).not.toHaveBeenCalled() + expect(readFile).not.toHaveBeenCalled() + }) + + it('never reads the client filesystem for a runtime host', async () => { + // Why: a runtime host's files live on that server. It is not "local with no provider". + vi.mocked(stat).mockClear() + vi.mocked(readFile).mockClear() + await expect( - detectRepoFileIcon('/repo', { connectionId: 'ssh-target-1', fsProvider: undefined }) + detectRepoFileIcon('/repo', { + kind: 'runtime', + hostId: 'runtime:env-a', + environmentId: 'env-a' + }) ).resolves.toBeNull() expect(stat).not.toHaveBeenCalled() expect(readFile).not.toHaveBeenCalled() }) - it('still probes the local filesystem for a repo with no connection', async () => { + it('still probes the local filesystem for a repo on this machine', async () => { vi.mocked(stat).mockClear() - await expect(detectRepoFileIcon('/repo', { connectionId: null })).resolves.toBeNull() + await expect( + detectRepoFileIcon('/repo', { kind: 'local', hostId: 'local' }) + ).resolves.toBeNull() expect(stat).toHaveBeenCalled() }) diff --git a/src/main/repo-icon-file-detection.ts b/src/main/repo-icon-file-detection.ts index 78ee3a97bec..f4289924b08 100644 --- a/src/main/repo-icon-file-detection.ts +++ b/src/main/repo-icon-file-detection.ts @@ -1,6 +1,7 @@ import { readFile, stat } from 'node:fs/promises' import { buildImageDataUri } from '../shared/image-data-uri' import { MAX_REPO_ICON_UPLOAD_BYTES, type RepoIcon } from '../shared/repo-icon' +import type { ExecutionHostFilesystemRoute } from './providers/execution-host-provider-dispatch' import type { IFilesystemProvider } from './providers/types' import { iconHrefCandidates } from './repo-icon-href-candidates' import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' @@ -258,20 +259,26 @@ async function detectRemoteImageIcon( return null } +/** + * Takes the resolved host route rather than a `connectionId`, because the incumbent shape spelled + * "this is local", "this host is unreachable" and "this is a runtime host" all as a falsy id — and + * only the first of those may read this machine's filesystem. + */ export function detectRepoFileIcon( repoPath: string, - { - connectionId, - fsProvider - }: { connectionId?: string | null; fsProvider?: IFilesystemProvider } = {} + route: ExecutionHostFilesystemRoute ): Promise { - if (fsProvider) { - return detectRemoteImageIcon(repoPath, fsProvider) + switch (route.kind) { + case 'local': + return detectLocalImageIcon(repoPath) + case 'ssh': + // A dropped provider fails closed: repoPath lives on the SSH host, so a same-named local + // path would hand back another repository's icon. + return route.provider + ? detectRemoteImageIcon(repoPath, route.provider) + : Promise.resolve(null) + case 'runtime': + // That environment's server holds these files; this process has no route to them. + return Promise.resolve(null) } - if (connectionId) { - // Why: repoPath lives on the SSH host, so a dropped provider must fail closed — - // a same-named local path would hand back another repository's icon. - return Promise.resolve(null) - } - return detectLocalImageIcon(repoPath) } diff --git a/src/main/runtime/repo-icon-fork-backfill.test.ts b/src/main/runtime/repo-icon-fork-backfill.test.ts index cc0014b19d2..b367fed0bb3 100644 --- a/src/main/runtime/repo-icon-fork-backfill.test.ts +++ b/src/main/runtime/repo-icon-fork-backfill.test.ts @@ -94,6 +94,24 @@ describe('startup fork-upstream backfill', () => { }) }) + it('skips every row whose files sit on an SSH host', async () => { + // Why: the incumbent guard read `repo.connectionId`, so a row minted with only the unified + // spelling fell through and had its upstream read off this client's copy of the path. A + // runtime row's nested target holds its files too, and is equally not ours to read. + const runtime = new OrcaRuntimeService() + const updateRepo = attachStore(runtime, [ + makeRepo({ id: 'repo-ssh', executionHostId: 'ssh:builder' }), + makeRepo({ id: 'repo-openclaw', executionHostId: 'ssh:openclaw' }), + makeRepo({ id: 'repo-nested', connectionId: 'nested-1', executionHostId: 'runtime:env-a' }) + ]) + + await (runtime as unknown as BackfillInternals).repositoryForkBackfill.run() + + expect(getRepoUpstream).not.toHaveBeenCalled() + expect(getRepoSlug).not.toHaveBeenCalled() + expect(updateRepo).not.toHaveBeenCalled() + }) + it('keeps an icon chosen while avatar detection is pending', async () => { const runtime = new OrcaRuntimeService() const repo = makeRepo() diff --git a/src/main/runtime/runtime-repository-clone-controller.ts b/src/main/runtime/runtime-repository-clone-controller.ts index fa5b9b100fe..1c65e414462 100644 --- a/src/main/runtime/runtime-repository-clone-controller.ts +++ b/src/main/runtime/runtime-repository-clone-controller.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' -import type { ExecutionHostId } from '../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { @@ -161,7 +161,13 @@ export class RuntimeRepositoryCloneController { } return existing } - const detected = await detectRepoIconAndUpstream({ repoPath: clonePath, kind: 'git' }) + // `cloneRepo` ran `git clone` in this process (see `assertCloneHostIsSupported`), so the + // checkout is here regardless of the host id stamped on the row. + const detected = await detectRepoIconAndUpstream({ + repoPath: clonePath, + kind: 'git', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) const repo: Repo = { id: randomUUID(), path: clonePath, diff --git a/src/main/runtime/runtime-repository-fork-backfill.ts b/src/main/runtime/runtime-repository-fork-backfill.ts index 36e9382d35a..df9d769f167 100644 --- a/src/main/runtime/runtime-repository-fork-backfill.ts +++ b/src/main/runtime/runtime-repository-fork-backfill.ts @@ -1,3 +1,4 @@ +import { getRepoSshConnectionId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import type { Repo } from '../../shared/repo-types' import { getRepoUpstream } from '../github/client' @@ -28,7 +29,10 @@ export class RuntimeRepositoryForkBackfill { } let changed = false for (const repo of store.getRepos()) { - if (repo.upstream !== undefined || repo.kind === 'folder' || repo.connectionId) { + // Why the resolved SSH target and not the raw `connectionId`: this backfill runs `gh`/git + // in this process, so any row whose files sit on an SSH host must be skipped — including + // one that carries only `executionHostId: ssh:…`, which the raw field reads as local. + if (repo.upstream !== undefined || repo.kind === 'folder' || getRepoSshConnectionId(repo)) { continue } let upstream: GitHubOwnerRepo | null @@ -39,7 +43,7 @@ export class RuntimeRepositoryForkBackfill { } const repoIcon = upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github' - ? await detectGitHubAvatarIcon(repo.path, null, upstream) + ? await detectGitHubAvatarIcon(repo.path, LOCAL_EXECUTION_HOST_ID, upstream) : null const current = store.getRepos().find((candidate) => candidate.id === repo.id) if (!current || current.upstream !== undefined) { diff --git a/src/main/runtime/runtime-repository-registration-controller.ts b/src/main/runtime/runtime-repository-registration-controller.ts index 1e601a092ed..cc64e20a9ac 100644 --- a/src/main/runtime/runtime-repository-registration-controller.ts +++ b/src/main/runtime/runtime-repository-registration-controller.ts @@ -2,7 +2,11 @@ import { randomUUID } from 'node:crypto' import { mkdir, readdir, rm, stat } from 'node:fs/promises' import { isAbsolute, join } from 'node:path' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' -import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import { gitExecFileAsync, awaitWindowsHostGitEnvironmentReady } from '../git/runner' import { getRepoName, isGitRepo } from '../git/repo' @@ -57,7 +61,14 @@ export class RuntimeRepositoryRegistrationController { } return existing } - const detected = await detectRepoIconAndUpstream({ repoPath: path, kind }) + // Local on purpose, whatever `executionHostId` stamps on the row: this controller already + // validated and will read `path` in this process. A `runtime:` stamp is how a paired client + // addresses the row, not a second machine holding the files. + const detected = await detectRepoIconAndUpstream({ + repoPath: path, + kind, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) const repo: Repo = { id: randomUUID(), path, @@ -137,7 +148,11 @@ export class RuntimeRepositoryRegistrationController { if (raceWinner) { return { repo: raceWinner } } - const detected = await detectRepoIconAndUpstream({ repoPath: targetPath, kind: repoKind }) + const detected = await detectRepoIconAndUpstream({ + repoPath: targetPath, + kind: repoKind, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) const repo: Repo = { id: randomUUID(), path: targetPath,