diff --git a/src/main/git/remote-name-listing.test.ts b/src/main/git/remote-name-listing.test.ts index ebf78e31367..e26905fc92e 100644 --- a/src/main/git/remote-name-listing.test.ts +++ b/src/main/git/remote-name-listing.test.ts @@ -1,22 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - getSshGitProviderMock, - getSshGitProviderGenerationMock, - readLocalGitConfigSignatureMock -} = vi.hoisted(() => ({ +const { gitExecFileAsyncMock, readLocalGitConfigSignatureMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0), readLocalGitConfigSignatureMock: vi.fn<() => Promise>(async () => 'sig-1') })) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: getSshGitProviderGenerationMock -})) vi.mock('../github/local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) @@ -38,9 +27,6 @@ describe('cached git remote name listing', () => { beforeEach(() => { _resetRemoteNameListingCache() gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() - getSshGitProviderGenerationMock.mockReset() - getSshGitProviderGenerationMock.mockReturnValue(0) readLocalGitConfigSignatureMock.mockReset() readLocalGitConfigSignatureMock.mockImplementation(async () => 'sig-1') }) @@ -180,20 +166,7 @@ describe('cached git remote name listing', () => { }) }) - it('lists remotes through the SSH git provider', async () => { - const exec = vi.fn(async () => ({ stdout: 'origin\n', stderr: '' })) - getSshGitProviderMock.mockReturnValue({ exec }) - - await expect(shouldProbeGitRemote('/remote/repo', 'upstream', 'ssh-1')).resolves.toBe(false) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - expect(exec).toHaveBeenCalledWith(['remote'], '/remote/repo', { - signal: expect.any(AbortSignal) - }) - }) - - it('fails open when the SSH git provider is missing instead of listing locally', async () => { - getSshGitProviderMock.mockReturnValue(undefined) - + it('fails open for an SSH repo instead of listing locally', async () => { await expect(shouldProbeGitRemote('/remote/repo', 'upstream', 'ssh-1')).resolves.toBe(true) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/git/remote-name-listing.ts b/src/main/git/remote-name-listing.ts index 17aa9c9444a..fc563fac780 100644 --- a/src/main/git/remote-name-listing.ts +++ b/src/main/git/remote-name-listing.ts @@ -1,5 +1,4 @@ import { readLocalGitConfigSignature } from '../github/local-git-config-signature' -import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe' import type { GitAdmissionTier } from './command-runner/git-exec-options' import { REMOTE_URL_PROBE_TIMEOUT_MS } from './remote-url-probe' @@ -38,13 +37,9 @@ function parseRemoteNames(stdout: string): string[] { function remoteNameListingCacheKey( repoPath: string, - connectionId?: string | null, localGitOptions: RemoteNameListingGitOptions = {} ): string { - const runtimeKey = connectionId - ? `ssh:${connectionId}:${getSshGitProviderGeneration(connectionId)}` - : `local:${localGitOptions.wslDistro ?? 'host'}` - return `${runtimeKey}\0${repoPath}` + return `local:${localGitOptions.wslDistro ?? 'host'}\0${repoPath}` } function pruneRemoteNameListingCache(now: number): void { @@ -64,12 +59,11 @@ function pruneRemoteNameListingCache(now: number): void { function listingGitConfigContext( repoPath: string, - connectionId?: string | null, localGitOptions: RemoteNameListingGitOptions = {} -): { repoPath: string; connectionId: string | null; wslDistro?: string } { +): { repoPath: string; connectionId: null; wslDistro?: string } { return { repoPath, - connectionId: connectionId ?? null, + connectionId: null, ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) } } @@ -77,21 +71,25 @@ function listingGitConfigContext( /** * `git remote` names for one repo/runtime. Failed listings are not cached: a * missed `upstream` would otherwise send issue/PR resolvers to origin on a - * contributor clone (#7331). + * contributor clone (#7331). An `ssh:` repo is not served here, so it lists as + * failed (`null`), never as this machine's remotes. */ export async function listCachedRemoteNames( repoPath: string, connectionId?: string | null, localGitOptions: RemoteNameListingGitOptions = {} ): Promise { - const cacheKey = remoteNameListingCacheKey(repoPath, connectionId, localGitOptions) + if (connectionId) { + return null + } + const cacheKey = remoteNameListingCacheKey(repoPath, localGitOptions) const now = Date.now() pruneRemoteNameListingCache(now) const cached = remoteNameListingCache.get(cacheKey) if (cached && cached.expiresAt > now) { if (cached.configSignature !== undefined) { const currentSignature = await readLocalGitConfigSignature( - listingGitConfigContext(repoPath, connectionId, localGitOptions) + listingGitConfigContext(repoPath, localGitOptions) ) if (currentSignature === cached.configSignature) { return cached.remotes @@ -103,9 +101,9 @@ export async function listCachedRemoteNames( } return runCoalescedProbe(remoteNameListingInFlight, cacheKey, async (ownsKey) => { - const configContext = listingGitConfigContext(repoPath, connectionId, localGitOptions) + const configContext = listingGitConfigContext(repoPath, localGitOptions) const configSignatureBefore = await readLocalGitConfigSignature(configContext) - const remotes = await listUncachedRemoteNames(repoPath, connectionId, localGitOptions) + const remotes = await listUncachedRemoteNames(repoPath, localGitOptions) if (remotes === null) { return null } @@ -132,23 +130,8 @@ export async function listCachedRemoteNames( async function listUncachedRemoteNames( repoPath: string, - connectionId?: string | null, localGitOptions: RemoteNameListingGitOptions = {} ): Promise { - if (connectionId) { - const provider = getSshGitProvider(connectionId) - if (!provider) { - return null - } - try { - const { stdout } = await provider.exec(['remote'], repoPath, { - signal: AbortSignal.timeout(REMOTE_URL_PROBE_TIMEOUT_MS) - }) - return parseRemoteNames(stdout) - } catch { - return null - } - } try { const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath, diff --git a/src/main/ipc/hooks/worktree-hook-execution-host.test.ts b/src/main/ipc/hooks/worktree-hook-execution-host.test.ts index 181d5a441d5..d38ac9b3b4a 100644 --- a/src/main/ipc/hooks/worktree-hook-execution-host.test.ts +++ b/src/main/ipc/hooks/worktree-hook-execution-host.test.ts @@ -142,15 +142,16 @@ function makeRepo(fields: Pick): Repo ) } +const localCalls = [ + mocks.hasHooks, + mocks.localRead, + mocks.localWrite, + mocks.localFileRead, + mocks.localStat, + mocks.effectiveHooks +] + function expectRoute(target: string | null): void { - const localCalls = [ - mocks.hasHooks, - mocks.localRead, - mocks.localWrite, - mocks.localFileRead, - mocks.localStat, - mocks.effectiveHooks - ] if (target) { expect(mocks.provider).toHaveBeenCalledWith(target) for (const [connectionId] of mocks.provider.mock.calls) { @@ -209,13 +210,15 @@ describe.each(owners)('runtime hooks: $label', ({ fields, target }) => { } else { await issue.write(repo.id, 'save') } - expectRoute(target) - if (method === 'write' && target) { - expect(mocks.ignored).toHaveBeenCalledWith(repo.path, target) - expect(mocks.remoteWrite).toHaveBeenCalledWith( - '/remote/fixture/.orca/issue-command', - 'save\n' - ) + if (target) { + // The runtime serves no SSH filesystem: an SSH owner is neither read remotely nor locally. + expect(mocks.provider).not.toHaveBeenCalled() + expect(mocks.remoteWrite).not.toHaveBeenCalled() + for (const method of localCalls) { + expect(method).not.toHaveBeenCalled() + } + } else { + expectRoute(target) } } ) diff --git a/src/main/ports/workspace-port-execution-host.test.ts b/src/main/ports/workspace-port-execution-host.test.ts index 6f111289036..7d1cefd9088 100644 --- a/src/main/ports/workspace-port-execution-host.test.ts +++ b/src/main/ports/workspace-port-execution-host.test.ts @@ -43,17 +43,8 @@ function localWorkspaceScan(): WorkspacePortScanResult { } } -function deps( - request?: (method: string) => Promise, - connected = true -): WorkspacePortExecutionHostDeps & { request: ReturnType } { - const requestMock = vi.fn(request ?? (async () => ({ ports: [], platform: 'linux' }))) - return { - request: requestMock, - getLocalProbes: async () => [LOCAL_PROBE], - getSshMultiplexer: (targetId) => - connected && targetId === 'box' ? { request: requestMock } : undefined - } +function deps(): WorkspacePortExecutionHostDeps { + return { getLocalProbes: async () => [LOCAL_PROBE] } } describe('scanWorkspacePortsOnExecutionHost', () => { @@ -69,64 +60,16 @@ describe('scanWorkspacePortsOnExecutionHost', () => { const result = await scanWorkspacePortsOnExecutionHost('local', d) expect(scanWorkspacePortsMock).toHaveBeenCalledWith([LOCAL_PROBE], undefined, undefined) - expect(d.request).not.toHaveBeenCalled() expect(result).toMatchObject({ executionHostId: 'local', platform: 'linux' }) expect(result.ports).toHaveLength(1) }) - it("scans the workspace's SSH host through its relay, never this host", async () => { - const d = deps(async () => ({ - platform: 'linux', - ports: [{ port: PORT, host: '0.0.0.0', pid: PID, processName: 'node' }] - })) - - const result = await scanWorkspacePortsOnExecutionHost('ssh:box', d) + it('reports an SSH host as unavailable, not as an empty host, and never scans this host', async () => { + const result = await scanWorkspacePortsOnExecutionHost('ssh:box', deps()) expect(scanWorkspacePortsMock).not.toHaveBeenCalled() - expect(d.request).toHaveBeenCalledWith('ports.detect', undefined, expect.any(Object)) - expect(result).toMatchObject({ - executionHostId: 'ssh:box', - platform: 'linux', - ports: [ - { - kind: 'external', - bindHost: '0.0.0.0', - connectHost: 'localhost', - port: PORT, - pid: PID, - processName: 'node' - } - ] - }) - expect(result.unavailableReason).toBeUndefined() - }) - - it('reports a disconnected SSH host as unavailable, not as an empty host', async () => { - const result = await scanWorkspacePortsOnExecutionHost('ssh:box', deps(undefined, false)) - expect(result).toMatchObject({ executionHostId: 'ssh:box', ports: [] }) - expect(result.unavailableReason).toMatch(/not connected/) - }) - - it('reports a failed or unsupported relay scan as unavailable', async () => { - const failed = await scanWorkspacePortsOnExecutionHost( - 'ssh:box', - deps(async () => { - throw new Error('Request "ports.detect" timed out after 10000ms') - }) - ) - const unsupported = await scanWorkspacePortsOnExecutionHost( - 'ssh:box', - deps(async () => ({ ports: [], platform: 'darwin' })) - ) - const malformed = await scanWorkspacePortsOnExecutionHost( - 'ssh:box', - deps(async () => ({ ports: [{ port: 'x' }], platform: 'linux' })) - ) - - expect(failed.unavailableReason).toMatch(/timed out/) - expect(unsupported.unavailableReason).toMatch(/not supported/) - expect(malformed.unavailableReason).toMatch(/invalid/) + expect(result.unavailableReason).toMatch(/Reconnect/) }) it('refuses a workspace hosted by another server instead of scanning here', async () => { @@ -134,7 +77,6 @@ describe('scanWorkspacePortsOnExecutionHost', () => { const result = await scanWorkspacePortsOnExecutionHost('runtime:env-2', d) expect(scanWorkspacePortsMock).not.toHaveBeenCalled() - expect(d.request).not.toHaveBeenCalled() expect(result.unavailableReason).toBeTruthy() }) }) @@ -192,6 +134,5 @@ describe('killWorkspacePortOnExecutionHost', () => { reason: 'Stopping processes on an SSH host is not supported yet.' }) expect(kill).not.toHaveBeenCalled() - expect(d.request).not.toHaveBeenCalled() }) }) diff --git a/src/main/ports/workspace-port-execution-host.ts b/src/main/ports/workspace-port-execution-host.ts index 5d2b7612eb0..df1a477857c 100644 --- a/src/main/ports/workspace-port-execution-host.ts +++ b/src/main/ports/workspace-port-execution-host.ts @@ -3,34 +3,18 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import type { DetectedPort } from '../../shared/ssh-types' import type { - WorkspacePort, WorkspacePortHostKillRequest, WorkspacePortHostScanResult, WorkspacePortKillResult, WorkspacePortProbe } from '../../shared/workspace-ports' -import type { SshMultiplexerRequestOptions } from '../ssh/ssh-channel-multiplexer' -import { connectHostForBindHost } from './local-workspace-port-address' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' import { killWorkspacePort, scanWorkspacePortProbes } from './workspace-port-ownership' -// Why: the relay walks /proc/*/fd on demand; a slow host must answer "unavailable", not hang the RPC. -const SSH_PORT_SCAN_TIMEOUT_MS = 10_000 - -type SshPortScanMultiplexer = { - request: ( - method: string, - params?: Record, - options?: SshMultiplexerRequestOptions - ) => Promise -} - export type WorkspacePortExecutionHostDeps = { /** Workspaces this host runs itself; the only processes Stop may signal here. */ getLocalProbes: () => Promise - /** Undefined means "not connected", never "the host's processes exited". */ - getSshMultiplexer: (targetId: string) => SshPortScanMultiplexer | undefined } function unavailable(executionHostId: string, reason: string): WorkspacePortHostScanResult { @@ -43,73 +27,6 @@ function unavailable(executionHostId: string, reason: string): WorkspacePortHost } } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function isDetectedPort(value: unknown): value is DetectedPort { - if (!value || typeof value !== 'object') { - return false - } - const port: { port?: unknown; host?: unknown; pid?: unknown; processName?: unknown } = value - return ( - typeof port.port === 'number' && - Number.isSafeInteger(port.port) && - typeof port.host === 'string' && - (port.pid === undefined || typeof port.pid === 'number') && - (port.processName === undefined || typeof port.processName === 'string') - ) -} - -// Why `external`: the relay reports listeners without workspace attribution, so no row it returns -// can be treated as workspace-owned (which is what makes a row stoppable). -function toUnattributedPort(port: DetectedPort): WorkspacePort { - return { - id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`, - kind: 'external', - bindHost: port.host, - connectHost: connectHostForBindHost(port.host), - port: port.port, - ...(port.pid !== undefined ? { pid: port.pid } : {}), - ...(port.processName !== undefined ? { processName: port.processName } : {}), - protocol: 'unknown' - } -} - -async function scanSshExecutionHost( - executionHostId: string, - targetId: string, - deps: WorkspacePortExecutionHostDeps -): Promise { - let response: unknown - try { - const mux = deps.getSshMultiplexer(targetId) - if (!mux) { - return unavailable(executionHostId, 'The SSH host is not connected.') - } - response = await mux.request('ports.detect', undefined, { - timeoutMs: SSH_PORT_SCAN_TIMEOUT_MS - }) - } catch (error) { - return unavailable(executionHostId, `Could not scan the SSH host: ${errorMessage(error)}`) - } - const result: { ports?: unknown; platform?: unknown } = - response && typeof response === 'object' ? response : {} - if (!Array.isArray(result.ports) || !result.ports.every(isDetectedPort)) { - return unavailable(executionHostId, 'The SSH host returned an invalid port scan.') - } - // Why: the relay answers an empty list on platforms it cannot scan; that is "could not look". - if (result.platform !== 'linux' && result.platform !== 'win32') { - return unavailable(executionHostId, 'Port detection is not supported on this SSH host.') - } - return { - executionHostId, - platform: result.platform, - scannedAt: Date.now(), - ports: result.ports.map(toUnattributedPort) - } -} - /** Scans the execution host a workspace resolved to on this endpoint, and names it on the result. */ export async function scanWorkspacePortsOnExecutionHost( executionHostId: ExecutionHostId, @@ -119,8 +36,12 @@ export async function scanWorkspacePortsOnExecutionHost( if (host?.kind === 'local') { return { ...(await scanWorkspacePortProbes(await deps.getLocalProbes())), executionHostId } } + // Why: this process serves no SSH host, so its listeners are unavailable, never "none". if (host?.kind === 'ssh') { - return scanSshExecutionHost(executionHostId, host.targetId, deps) + return unavailable( + executionHostId, + `Could not scan the SSH host: ${SSH_HOST_NOT_SERVED_MESSAGE}` + ) } return unavailable(executionHostId, 'This workspace does not run on this server.') } diff --git a/src/main/provisioned-root-checkout.test.ts b/src/main/provisioned-root-checkout.test.ts new file mode 100644 index 00000000000..dcc7f1b812a --- /dev/null +++ b/src/main/provisioned-root-checkout.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { verifyProvisionedRootCheckout } from './provisioned-root-checkout' + +const projectRoot = '/workspace/orca' + +function gitWorktree(path: string, overrides: Partial = {}): GitWorktreeInfo { + return { + path, + head: 'abc123', + branch: 'refs/heads/fix-sandbox', + isBare: false, + isMainWorktree: true, + ...overrides + } +} + +function verify( + worktree: GitWorktreeInfo, + request: Partial[0]['request']> = {}, + sparseCheckoutEnabled = false +): GitWorktreeInfo { + return verifyProvisionedRootCheckout({ + worktrees: [worktree], + sparseCheckoutEnabled, + projectRoot, + request: { name: 'fix-sandbox', ...request } + }) +} + +describe('verifyProvisionedRootCheckout', () => { + it('accepts the primary checkout on the requested branch', () => { + expect(verify(gitWorktree(projectRoot)).path).toBe(projectRoot) + }) + + it('rejects a checkout on a branch Orca did not request', () => { + expect(() => verify(gitWorktree(projectRoot, { branch: 'refs/heads/wrong' }))).toThrow( + 'requested branch' + ) + }) + + it('rejects a checkout that did not start from the requested ref', () => { + expect(() => + verify(gitWorktree(projectRoot, { head: 'wrong-head' }), { + baseBranch: 'v1.2.3', + expectedRefHead: 'expected-head' + }) + ).toThrow('requested ref') + }) + + it('accepts a requested branch created from a tag or commit ref', () => { + expect( + verify(gitWorktree(projectRoot), { baseBranch: 'v1.2.3', expectedRefHead: 'abc123' }).branch + ).toBe('refs/heads/fix-sandbox') + }) + + it('rejects a requested ref without its source-host commit identity', () => { + expect(() => verify(gitWorktree(projectRoot), { baseBranch: 'missing-ref' })).toThrow( + 'ref identity is missing' + ) + }) + + it('uses an explicit branch override as the requested local branch', () => { + expect( + verify(gitWorktree(projectRoot, { branch: 'refs/heads/feature/review' }), { + branchNameOverride: 'feature/review' + }).branch + ).toBe('refs/heads/feature/review') + }) + + it('rejects a linked worktree and a sparse checkout', () => { + expect(() => verify(gitWorktree(projectRoot, { isMainWorktree: false }))).toThrow( + 'must be the repository primary checkout' + ) + expect(() => verify(gitWorktree(projectRoot), {}, true)).toThrow( + 'cannot adopt a sparse checkout' + ) + }) + + it('compares Windows checkout roots using runtime path semantics', () => { + expect( + verifyProvisionedRootCheckout({ + worktrees: [gitWorktree('c:/workspace/orca/')], + sparseCheckoutEnabled: false, + projectRoot: 'C:\\Workspace\\Orca', + request: { name: 'fix-sandbox' } + }).path + ).toBe('c:/workspace/orca/') + }) +}) diff --git a/src/main/provisioned-root-ssh-adoption.test.ts b/src/main/provisioned-root-ssh-adoption.test.ts index 1927e072880..c67ad0945cb 100644 --- a/src/main/provisioned-root-ssh-adoption.test.ts +++ b/src/main/provisioned-root-ssh-adoption.test.ts @@ -6,16 +6,10 @@ import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import type { AdoptProvisionedRootArgs } from '../shared/worktree/create-types' import type { WorktreeMeta } from '../shared/worktree/meta-types' -import type { GitWorktreeInfo } from '../shared/worktree/types' import { listEphemeralVmRuntimes, upsertEphemeralVmRuntime } from '../shared/ephemeral-vm-runtime-store' -import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch' -import { - resetSshProviderAuthorities, - rotateSshProviderAuthority -} from './ssh/ssh-provider-authority' import { adoptProvisionedRootSshCheckout } from './provisioned-root-ssh-adoption' const connectionId = 'runtime-ssh-test' @@ -26,88 +20,14 @@ describe('adoptProvisionedRootSshCheckout', () => { beforeEach(() => { userDataPath = mkdtempSync(join(tmpdir(), 'orca-provisioned-root-')) - resetSshProviderAuthorities() }) afterEach(() => { - unregisterSshGitProvider(connectionId) - resetSshProviderAuthorities() rmSync(userDataPath, { recursive: true, force: true }) }) - it('adopts the exact primary checkout, persists host metadata, and attaches the runtime', async () => { + it('refuses a valid request because this process serves no SSH git, without writing state', async () => { seedRuntime(userDataPath, projectRoot) - registerSshGitProvider(connectionId, { - listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]), - exec: sparseCheckoutProbe(false) - } as never) - const { store, setWorktreeMeta } = makeStore() - - const result = await adoptProvisionedRootSshCheckout({ - userDataPath, - request: request(projectRoot), - repo: repo(projectRoot), - store, - isRepoCurrent: () => true - }) - - expect(result.worktree).toMatchObject({ - id: `repo-1::${projectRoot}`, - path: projectRoot, - isMainWorktree: true, - hostId: `ssh:${connectionId}`, - ephemeralVmCheckoutMode: 'provisioned-root', - linkedGitLabIssue: 17 - }) - expect(setWorktreeMeta).toHaveBeenCalledWith( - `repo-1::${projectRoot}`, - expect.objectContaining({ - hostId: `ssh:${connectionId}`, - ephemeralVmCheckoutMode: 'provisioned-root', - linkedGitLabIssue: 17 - }) - ) - expect(listEphemeralVmRuntimes(userDataPath)[0]).toMatchObject({ - workspaceId: `repo-1::${projectRoot}`, - status: 'running' - }) - }) - - it('pins an explicit label even when it equals the adopted branch', async () => { - seedRuntime(userDataPath, projectRoot) - registerSshGitProvider(connectionId, { - listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]), - exec: sparseCheckoutProbe(false) - } as never) - const { store, setWorktreeMeta } = makeStore() - - const result = await adoptProvisionedRootSshCheckout({ - userDataPath, - request: { - ...request(projectRoot), - displayName: 'fix-sandbox', - displayNameKind: 'user' - }, - repo: repo(projectRoot), - store, - isRepoCurrent: () => true - }) - - expect(result.worktree.displayName).toBe('fix-sandbox') - expect(setWorktreeMeta).toHaveBeenCalledWith( - `repo-1::${projectRoot}`, - expect.objectContaining({ displayName: 'fix-sandbox', displayNameIsPinned: true }) - ) - }) - - it('rejects a recipe checkout on a branch Orca did not request', async () => { - seedRuntime(userDataPath, projectRoot) - registerSshGitProvider(connectionId, { - listWorktrees: vi - .fn() - .mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/wrong-branch' })]), - exec: sparseCheckoutProbe(false) - } as never) const { store, setWorktreeMeta } = makeStore() await expect( @@ -118,121 +38,15 @@ describe('adoptProvisionedRootSshCheckout', () => { store, isRepoCurrent: () => true }) - ).rejects.toThrow('requested branch') + ).rejects.toThrow('no longer available') expect(setWorktreeMeta).not.toHaveBeenCalled() + expect(listEphemeralVmRuntimes(userDataPath)[0]?.workspaceId).toBeUndefined() }) - it('rejects a recipe checkout that did not start from the requested ref', async () => { + it('rejects sparse checkout requests', async () => { seedRuntime(userDataPath, projectRoot) - const exec = sparseCheckoutProbe(false) - registerSshGitProvider(connectionId, { - listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot, { head: 'wrong-head' })]), - exec - } as never) - const { store, setWorktreeMeta } = makeStore() - - await expect( - adoptProvisionedRootSshCheckout({ - userDataPath, - request: { - ...request(projectRoot), - baseBranch: 'v1.2.3', - expectedRefHead: 'expected-head' - }, - repo: repo(projectRoot), - store, - isRepoCurrent: () => true - }) - ).rejects.toThrow('requested ref') - expect(exec).toHaveBeenCalledOnce() - expect(setWorktreeMeta).not.toHaveBeenCalled() - }) - - it('accepts an attached requested branch created from a tag or commit ref', async () => { - seedRuntime(userDataPath, projectRoot) - const exec = sparseCheckoutProbe(false) - registerSshGitProvider(connectionId, { - listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]), - exec - } as never) const { store } = makeStore() - await expect( - adoptProvisionedRootSshCheckout({ - userDataPath, - request: { - ...request(projectRoot), - baseBranch: 'v1.2.3', - expectedRefHead: 'abc123' - }, - repo: repo(projectRoot), - store, - isRepoCurrent: () => true - }) - ).resolves.toMatchObject({ worktree: { branch: 'refs/heads/fix-sandbox' } }) - }) - - it('rejects a requested ref without its source-host commit identity', async () => { - seedRuntime(userDataPath, projectRoot) - registerSshGitProvider(connectionId, { - listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]), - exec: sparseCheckoutProbe(false) - } as never) - const { store, setWorktreeMeta } = makeStore() - - await expect( - adoptProvisionedRootSshCheckout({ - userDataPath, - request: { ...request(projectRoot), baseBranch: 'missing-ref' }, - repo: repo(projectRoot), - store, - isRepoCurrent: () => true - }) - ).rejects.toThrow('ref identity is missing') - expect(setWorktreeMeta).not.toHaveBeenCalled() - }) - - it('uses an explicit branch override as the requested local branch', async () => { - seedRuntime(userDataPath, projectRoot) - registerSshGitProvider(connectionId, { - listWorktrees: vi - .fn() - .mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/feature/review' })]), - exec: sparseCheckoutProbe(false) - } as never) - const { store } = makeStore() - - await expect( - adoptProvisionedRootSshCheckout({ - userDataPath, - request: { ...request(projectRoot), branchNameOverride: 'feature/review' }, - repo: repo(projectRoot), - store, - isRepoCurrent: () => true - }) - ).resolves.toMatchObject({ worktree: { branch: 'refs/heads/feature/review' } }) - }) - - it('rejects a linked worktree and sparse checkout', async () => { - seedRuntime(userDataPath, projectRoot) - const listWorktrees = vi - .fn() - .mockResolvedValue([gitWorktree(projectRoot, { isMainWorktree: false })]) - registerSshGitProvider(connectionId, { - listWorktrees, - exec: sparseCheckoutProbe(false) - } as never) - const { store, setWorktreeMeta } = makeStore() - - await expect( - adoptProvisionedRootSshCheckout({ - userDataPath, - request: request(projectRoot), - repo: repo(projectRoot), - store, - isRepoCurrent: () => true - }) - ).rejects.toThrow('must be the repository primary checkout') await expect( adoptProvisionedRootSshCheckout({ userDataPath, @@ -242,15 +56,10 @@ describe('adoptProvisionedRootSshCheckout', () => { isRepoCurrent: () => true }) ).rejects.toThrow('do not support sparse checkout') - expect(setWorktreeMeta).not.toHaveBeenCalled() }) it('rejects path and runtime target mismatches', async () => { seedRuntime(userDataPath, projectRoot) - registerSshGitProvider(connectionId, { - listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]), - exec: sparseCheckoutProbe(false) - } as never) const { store } = makeStore() await expect( @@ -272,79 +81,6 @@ describe('adoptProvisionedRootSshCheckout', () => { }) ).rejects.toThrow('host does not match') }) - - it('rejects provider rotation during verification', async () => { - seedRuntime(userDataPath, projectRoot) - let resolveList: (value: ReturnType[]) => void = () => undefined - const listWorktrees = vi.fn( - () => - new Promise[]>((resolve) => { - resolveList = resolve - }) - ) - registerSshGitProvider(connectionId, { - listWorktrees, - exec: sparseCheckoutProbe(false) - } as never) - const { store } = makeStore() - const adoption = adoptProvisionedRootSshCheckout({ - userDataPath, - request: request(projectRoot), - repo: repo(projectRoot), - store, - isRepoCurrent: () => true - }) - await vi.waitFor(() => expect(listWorktrees).toHaveBeenCalledOnce()) - rotateSshProviderAuthority(connectionId) - resolveList([gitWorktree(projectRoot)]) - - await expect(adoption).rejects.toThrow('changed during checkout verification') - }) - - it('compares Windows checkout roots using runtime path semantics', async () => { - const windowsRoot = 'C:\\Workspace\\Orca' - seedRuntime(userDataPath, windowsRoot) - registerSshGitProvider(connectionId, { - listWorktrees: vi.fn().mockResolvedValue([gitWorktree('c:/workspace/orca/')]), - exec: sparseCheckoutProbe(false) - } as never) - const { store } = makeStore() - - const result = await adoptProvisionedRootSshCheckout({ - userDataPath, - request: request('C:/WORKSPACE/ORCA'), - repo: repo('c:\\workspace\\orca'), - store, - isRepoCurrent: () => true - }) - - expect(result.worktree.path).toBe('c:/workspace/orca/') - }) - - it('rejects sparse checkout enabled in the remote Git config', async () => { - seedRuntime(userDataPath, projectRoot) - const exec = sparseCheckoutProbe(true) - registerSshGitProvider(connectionId, { - listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]), - exec - } as never) - const { store, setWorktreeMeta } = makeStore() - - await expect( - adoptProvisionedRootSshCheckout({ - userDataPath, - request: request(projectRoot), - repo: repo(projectRoot), - store, - isRepoCurrent: () => true - }) - ).rejects.toThrow('cannot adopt a sparse checkout') - expect(exec).toHaveBeenCalledWith( - ['config', '--bool', '--get', '--default=false', 'core.sparseCheckout'], - projectRoot - ) - expect(setWorktreeMeta).not.toHaveBeenCalled() - }) }) function seedRuntime(userDataPath: string, root: string): void { @@ -398,21 +134,6 @@ function request(expectedPath: string): AdoptProvisionedRootArgs { } } -function gitWorktree(path: string, overrides: Partial = {}): GitWorktreeInfo { - return { - path, - head: 'abc123', - branch: 'refs/heads/fix-sandbox', - isBare: false, - isMainWorktree: true, - ...overrides - } -} - -function sparseCheckoutProbe(enabled: boolean): ReturnType { - return vi.fn().mockResolvedValue({ stdout: `${enabled}\n`, stderr: '' }) -} - function makeStore(): { store: Store setWorktreeMeta: ReturnType diff --git a/src/main/provisioned-root-ssh-adoption.ts b/src/main/provisioned-root-ssh-adoption.ts index f90aef7df94..fcf644c815c 100644 --- a/src/main/provisioned-root-ssh-adoption.ts +++ b/src/main/provisioned-root-ssh-adoption.ts @@ -12,18 +12,7 @@ import { } from '../shared/ephemeral-vm-recipes' import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store' import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' -import { getSshGitProvider } from './providers/ssh-git-dispatch' -import { - getSshProviderAuthority, - isCurrentSshProviderAuthority -} from './ssh/ssh-provider-authority' -import { attachEphemeralVmRuntimeToWorkspace } from './ephemeral-vm-runtime-attachment' -import { mergeWorktree } from './ipc/worktree-logic' -import { - buildProvisionedRootMeta, - provisionedRootPathsEqual, - verifyProvisionedRootCheckout -} from './provisioned-root-checkout' +import { provisionedRootPathsEqual } from './provisioned-root-checkout' type AdoptionArgs = AdoptProvisionedRootArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -36,7 +25,7 @@ export async function adoptProvisionedRootSshCheckout(args: { store: Store isRepoCurrent: () => boolean }): Promise { - const { request, repo, store } = args + const { request, repo } = args if (request.sparseCheckout) { throw new Error('Provisioned-root recipes do not support sparse checkout.') } @@ -61,59 +50,8 @@ export async function adoptProvisionedRootSshCheckout(args: { throw new Error('The recipe projectRoot does not match the imported Git checkout root.') } - const provider = getSshGitProvider(connectionId) - if (!provider) { - throw new Error('The recipe-created SSH connection is no longer available.') - } - const authority = { ...getSshProviderAuthority(connectionId) } - const [worktrees, sparseCheckoutEnabled] = await Promise.all([ - provider.listWorktrees(repo.path), - isSparseCheckoutEnabled(provider, projectRoot) - ]) - if ( - getSshGitProvider(connectionId) !== provider || - !isCurrentSshProviderAuthority(authority) || - !args.isRepoCurrent() - ) { - throw new Error('The recipe-created SSH connection changed during checkout verification.') - } - requireOwnedProvisionedRootRuntime(args.userDataPath, request.runtimeId, connectionId) - - const gitWorktree = verifyProvisionedRootCheckout({ - worktrees, - sparseCheckoutEnabled, - projectRoot, - request - }) - - const worktreeId = `${repo.id}::${gitWorktree.path}` - attachEphemeralVmRuntimeToWorkspace({ - userDataPath: args.userDataPath, - runtimeId: request.runtimeId, - workspaceId: worktreeId - }) - const now = Date.now() - const meta = store.setWorktreeMeta( - worktreeId, - buildProvisionedRootMeta(store, repo, request, { - branchName: gitWorktree.branch.replace(/^refs\/heads\//, ''), - now, - hostId: args.request.executionHostId, - source: 'ssh' - }) - ) - return { worktree: mergeWorktree(repo.id, gitWorktree, meta) } -} - -async function isSparseCheckoutEnabled( - provider: NonNullable>, - projectRoot: string -): Promise { - const { stdout } = await provider.exec( - ['config', '--bool', '--get', '--default=false', 'core.sparseCheckout'], - projectRoot - ) - return stdout.trim() === 'true' + // Why: this process serves no SSH git, so a provisioned SSH checkout is never adopted here. + throw new Error('The recipe-created SSH connection is no longer available.') } function requireOwnedProvisionedRootRuntime( diff --git a/src/main/runtime/orca-runtime-files-chunk-reads.test.ts b/src/main/runtime/orca-runtime-files-chunk-reads.test.ts new file mode 100644 index 00000000000..dc3b87019dc --- /dev/null +++ b/src/main/runtime/orca-runtime-files-chunk-reads.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { openMock, resolveAuthorizedPathMock } from './orca-runtime-files-mock-registry' +import { + createRuntimeFileCommands, + useRuntimeFileCommandsLifecycle +} from './orca-runtime-files-test-harness' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' + +vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock()) +vi.mock('fs/promises', async () => + (await import('./orca-runtime-files-mock-registry')).fsPromisesModuleMock() +) +vi.mock( + './file-watcher-host', + async () => (await import('./orca-runtime-files-mock-registry')).fileWatcherHostMock +) +vi.mock('../ipc/filesystem-auth', async () => + (await import('./orca-runtime-files-mock-registry')).filesystemAuthModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() +) +vi.mock( + '../ipc/local-worktree-runtime-options', + async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock +) + +describe('RuntimeFileCommands.readFileExplorerChunk', () => { + useRuntimeFileCommandsLifecycle() + + it('serves a local worktree from the local file handle', async () => { + const contents = Buffer.from(Array.from({ length: 2048 }, (_, index) => (index * 31) % 251)) + const { commands } = createRuntimeFileCommands() + resolveAuthorizedPathMock.mockResolvedValue('/repo/local.bin') + const close = vi.fn(async () => undefined) + openMock.mockResolvedValue({ + stat: vi.fn(async () => ({ isFile: () => true, size: contents.byteLength })), + read: vi.fn( + async (buffer: Buffer, bufferOffset: number, length: number, position: number) => { + const bytesRead = contents.copy(buffer, bufferOffset, position, position + length) + return { bytesRead } + } + ), + close + }) + + const chunk = await commands.readFileExplorerChunk('id:wt-1', 'local.bin', 0, 1024) + + expect(Buffer.from(chunk.contentBase64, 'base64')).toEqual(contents.subarray(0, 1024)) + expect(chunk.eof).toBe(false) + expect(close).toHaveBeenCalled() + }) + + it('refuses an SSH worktree without opening a local file', async () => { + const { commands } = createRuntimeFileCommands({ + path: '/remote/repo', + resolveRuntimeFileTarget: vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' }, + executionHostId: 'ssh:ssh-1' + })) + }) + + await expect(commands.readFileExplorerChunk('id:wt-1', 'big.bin', 0, 1024)).rejects.toThrow( + SshHostNotServedError + ) + expect(openMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts index ee2a71fdac2..403e928a255 100644 --- a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts +++ b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import type * as FileSystem from 'node:fs/promises' import { enoent, @@ -10,7 +11,6 @@ import { createRuntimeFileCommands, useRuntimeFileCommandsLifecycle } from './orca-runtime-files-test-harness' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock()) vi.mock('fs/promises', async () => @@ -30,10 +30,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) function dirEntry(args: { name: string; directory?: boolean; symlink?: boolean }) { return { @@ -181,42 +177,22 @@ describe('RuntimeFileCommands', () => { ) }) - it('names this desktop as the owner of SSH file and diff opens', async () => { + it('refuses SSH file opens without creating an editor tab', async () => { const openFile = vi.fn() - const openDiff = vi.fn() const resolveRuntimeFileTarget = vi.fn(async () => ({ worktree: { id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' }, executionHostId: 'ssh:ssh-1' })) const { commands } = createRuntimeFileCommands({ openFile, - openDiff, path: '/remote/repo', resolveRuntimeFileTarget }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the open path only calls `stat`. - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat: vi.fn().mockResolvedValue({ type: 'file', size: 1, mtime: 0 }) - } as never) - await commands.openMobileFile('id:wt-1', 'docs/readme.md') - await commands.openMobileDiff('id:wt-1', 'docs/readme.md', false) - - expect(openFile).toHaveBeenCalledWith( - 'wt-1', - '/remote/repo/docs/readme.md', - 'docs/readme.md', - null, - undefined - ) - expect(openDiff).toHaveBeenCalledWith( - 'wt-1', - '/remote/repo/docs/readme.md', - 'docs/readme.md', - false, - null, - undefined + await expect(commands.openMobileFile('id:wt-1', 'docs/readme.md')).rejects.toThrow( + SshHostNotServedError ) + expect(openFile).not.toHaveBeenCalled() }) it('names a paired server as the owner of a diff its catalog row is stamped with', async () => { @@ -288,31 +264,6 @@ describe('RuntimeFileCommands', () => { expect(openFile).not.toHaveBeenCalled() }) - it('rejects missing remote files without creating an editor tab', async () => { - const openFile = vi.fn() - const resolveRuntimeFileTarget = vi.fn(async () => ({ - worktree: { - id: 'wt-1', - repoId: 'repo-1', - path: '/remote/repo' - }, - executionHostId: 'ssh:ssh-1' - })) - const { commands } = createRuntimeFileCommands({ - openFile, - path: '/remote/repo', - resolveRuntimeFileTarget - }) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')) - } as never) - - await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow( - "ENOENT: no such file or directory, open '/remote/repo/docs/missing.md'" - ) - expect(openFile).not.toHaveBeenCalled() - }) - it('rejects a local directory without creating an editor tab', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) @@ -325,32 +276,6 @@ describe('RuntimeFileCommands', () => { expect(openFile).not.toHaveBeenCalled() }) - it('rejects a remote directory without creating an editor tab', async () => { - const openFile = vi.fn() - const resolveRuntimeFileTarget = vi.fn(async () => ({ - worktree: { - id: 'wt-1', - repoId: 'repo-1', - path: '/remote/repo' - }, - executionHostId: 'ssh:ssh-1' - })) - const { commands } = createRuntimeFileCommands({ - openFile, - path: '/remote/repo', - resolveRuntimeFileTarget - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the open path only calls `stat`. - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat: vi.fn().mockResolvedValue({ type: 'directory', size: 0, mtime: 0 }) - } as never) - - await expect(commands.openMobileFile('id:wt-1', 'src')).rejects.toThrow( - "EISDIR: illegal operation on a directory, open '/remote/repo/src'" - ) - expect(openFile).not.toHaveBeenCalled() - }) - it('does not follow symlinks when reading runtime-local file explorer dirs', async () => { const { commands } = createRuntimeFileCommands() resolveAuthorizedPathMock.mockResolvedValue('/repo') diff --git a/src/main/runtime/orca-runtime-files-preview-budget.test.ts b/src/main/runtime/orca-runtime-files-preview-budget.test.ts index 9b0511db281..48096782079 100644 --- a/src/main/runtime/orca-runtime-files-preview-budget.test.ts +++ b/src/main/runtime/orca-runtime-files-preview-budget.test.ts @@ -7,10 +7,8 @@ import { createRuntimeFileCommands, useRuntimeFileCommandsLifecycle } from './orca-runtime-files-test-harness' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES } from './orca-runtime-files' import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../shared/remote-rpc-content-budget' -import { FileReadCapExceededError, StreamProtocolError } from '../ssh/ssh-filesystem-stream-reader' vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock()) vi.mock('fs/promises', async () => @@ -30,10 +28,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) describe('RuntimeFileCommands', () => { useRuntimeFileCommandsLifecycle() @@ -106,152 +100,5 @@ describe('RuntimeFileCommands', () => { mimeType: 'image/png' }) }) - - it('rejects an SSH text preview past the decoded text limit the local branch enforces', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - // NUL-free control bytes: sniffed as text, yet each escapes to six JSON bytes. - const content = '\u0001'.repeat(1024 * 1024) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat: vi.fn().mockResolvedValue({ type: 'file', size: content.length }), - readFile: vi.fn().mockResolvedValue({ content, isBinary: false }) - } as never) - - await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt')).rejects.toThrow( - 'file_too_large' - ) - }) - - it('still returns an SSH binary preview inside the base64 cap', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const preview = { content: 'a'.repeat(1024 * 1024), isBinary: true, isImage: true } - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat: vi - .fn() - .mockResolvedValue({ type: 'file', size: RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES }), - readFile: vi.fn().mockResolvedValue(preview) - } as never) - - await expect(commands.readFileExplorerPreview('id:wt-1', 'logo.png')).resolves.toEqual( - preview - ) - }) - - it('rejects an SSH binary result that grew past its request-scoped budget', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const readFile = vi.fn().mockResolvedValue({ - content: 'a'.repeat(13), - isBinary: true, - isImage: true - }) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat: vi.fn().mockResolvedValue({ type: 'file', size: 0 }), - readFile - } as never) - - await expect(commands.readFileExplorerPreview('id:wt-1', 'logo.png', 12)).rejects.toThrow( - 'file_too_large' - ) - expect(readFile).toHaveBeenCalledWith('/repo/logo.png', { - maxBinaryBytes: 0, - maxTextBytes: 512 * 1024 - }) - }) - - it('rejects escape-dense SSH text beyond the request-scoped result budget', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat: vi.fn().mockResolvedValue({ type: 'file', size: 64 }), - readFile: vi.fn().mockResolvedValue({ content: '\u0001'.repeat(64), isBinary: false }) - } as never) - - await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt', 128)).rejects.toThrow( - 'file_too_large' - ) - }) - - // Why: without translation the reader's raw "exceeds client cap" string reaches the client as a - // generic runtime_error, which neither the desktop nor the mobile preview arm recognizes. - it('translates an over-cap stream read into file_too_large', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat: vi.fn().mockResolvedValue({ type: 'file', size: 1024 }), - readFile: vi - .fn() - .mockRejectedValue( - new FileReadCapExceededError('Reported totalSize 900000 exceeds client cap 524288') - ) - } as never) - - await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt')).rejects.toThrow( - 'file_too_large' - ) - }) - - it('leaves a genuine stream protocol failure unmasked', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat: vi.fn().mockResolvedValue({ type: 'file', size: 1024 }), - readFile: vi.fn().mockRejectedValue(new StreamProtocolError('Malformed chunk for stream 4')) - } as never) - - await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt')).rejects.toThrow( - 'Malformed chunk' - ) - }) - - it('rejects oversized SSH preview metadata with small content', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat: vi.fn().mockResolvedValue({ type: 'file', size: 1 }), - readFile: vi.fn().mockResolvedValue({ - content: 'a', - isBinary: true, - mimeType: 'x'.repeat(128) - }) - } as never) - - await expect(commands.readFileExplorerPreview('id:wt-1', 'logo.png', 128)).rejects.toThrow( - 'file_too_large' - ) - }) - - it('sends preview authority to the SSH execution host in one read', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const readDocPreviewFile = vi.fn().mockResolvedValue({ content: 'ok', isBinary: false }) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ readDocPreviewFile } as never) - - await expect( - commands.readDocPreviewFile('id:wt-1', 'docs/index.html', 'docs/index.html', 'docs', [ - 'assets' - ]) - ).resolves.toEqual({ content: 'ok', isBinary: false }) - expect(readDocPreviewFile).toHaveBeenCalledWith({ - boundaryPath: '/repo', - entryPath: '/repo/docs/index.html', - implicitRootPath: '/repo/docs', - authorizedRootPaths: ['/repo/assets'], - targetPath: '/repo/docs/index.html', - maxTextBytes: 512 * 1024, - maxBinaryBytes: 10 * 1024 * 1024 - }) - }) - - it('fails closed when the SSH execution host lacks scoped preview reads', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - vi.mocked(getSshFilesystemProvider).mockReturnValue({} as never) - - await expect( - commands.readDocPreviewFile('id:wt-1', 'docs/index.html', 'docs/index.html', 'docs', []) - ).rejects.toThrow('newer SSH relay') - }) }) }) diff --git a/src/main/runtime/orca-runtime-files-rename-authority.test.ts b/src/main/runtime/orca-runtime-files-rename-authority.test.ts index 0f8c8f6cca7..5a5d08c3ecf 100644 --- a/src/main/runtime/orca-runtime-files-rename-authority.test.ts +++ b/src/main/runtime/orca-runtime-files-rename-authority.test.ts @@ -9,7 +9,7 @@ import { createRuntimeFileCommands, useRuntimeFileCommandsLifecycle } from './orca-runtime-files-test-harness' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { setSshConnectionGeneration } from '../ssh/ssh-connection-generation' vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock()) @@ -30,10 +30,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) function mockStats(dev: number, ino: number) { return { dev, ino, isDirectory: () => false } @@ -76,7 +72,6 @@ describe('RuntimeFileCommands', () => { 'newer Orca client' ) - expect(getSshFilesystemProvider).not.toHaveBeenCalled() expect(renameMock).not.toHaveBeenCalled() }) @@ -88,7 +83,6 @@ describe('RuntimeFileCommands', () => { commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1') ).rejects.toThrow('newer Orca client') - expect(getSshFilesystemProvider).not.toHaveBeenCalled() expect(renameMock).not.toHaveBeenCalled() }) @@ -100,7 +94,6 @@ describe('RuntimeFileCommands', () => { commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', undefined, undefined, 'local') ).rejects.toThrow('Workspace host changed') - expect(getSshFilesystemProvider).not.toHaveBeenCalled() expect(renameMock).not.toHaveBeenCalled() }) @@ -111,7 +104,6 @@ describe('RuntimeFileCommands', () => { commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1') ).rejects.toThrow('Workspace host changed') - expect(getSshFilesystemProvider).not.toHaveBeenCalled() expect(renameMock).not.toHaveBeenCalled() }) @@ -190,22 +182,17 @@ describe('RuntimeFileCommands', () => { expect(renameMock).not.toHaveBeenCalled() }) - it('routes runtime remote rename through the SSH no-clobber provider method', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never) + it('refuses a matching SSH rename without renaming on this machine', async () => { const { commands, store } = createRuntimeFileCommands() store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1') - - expect(renameNoClobber).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts') - expect(store.getRepo).toHaveBeenCalledTimes(1) + await expect( + commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1') + ).rejects.toThrow(SshHostNotServedError) expect(renameMock).not.toHaveBeenCalled() }) it('rejects a mutation captured for an obsolete SSH connection generation', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never) const { commands, store } = createRuntimeFileCommands() store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) setSshConnectionGeneration('ssh-1', 8) @@ -213,12 +200,10 @@ describe('RuntimeFileCommands', () => { await expect( commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 7, 'ssh-1', 'ssh:ssh-1') ).rejects.toThrow('SSH connection changed') - expect(renameNoClobber).not.toHaveBeenCalled() + expect(renameMock).not.toHaveBeenCalled() }) it('rejects nested SSH mutations from clients without generation support', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never) const { commands, store } = createRuntimeFileCommands() store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) @@ -232,20 +217,17 @@ describe('RuntimeFileCommands', () => { 'ssh:ssh-1' ) ).rejects.toThrow('SSH connection changed') - expect(renameNoClobber).not.toHaveBeenCalled() + expect(renameMock).not.toHaveBeenCalled() }) it('rejects an equal-generation mutation captured for another SSH target', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never) const { commands, store } = createRuntimeFileCommands() store.getRepo.mockReturnValue({ connectionId: 'ssh-b' }) await expect( commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-a', 'ssh:ssh-a') ).rejects.toThrow('Workspace host changed') - expect(getSshFilesystemProvider).not.toHaveBeenCalled() - expect(renameNoClobber).not.toHaveBeenCalled() + expect(renameMock).not.toHaveBeenCalled() }) it('rejects a stale SSH expectation after the worktree becomes HUB-local', async () => { @@ -256,16 +238,4 @@ describe('RuntimeFileCommands', () => { ).rejects.toThrow('Workspace host changed') expect(renameMock).not.toHaveBeenCalled() }) - - it('propagates runtime remote no-clobber rename failures', async () => { - const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists')) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never) - const { commands, store } = createRuntimeFileCommands() - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - - await expect( - commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1') - ).rejects.toThrow('destination exists') - expect(renameMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/runtime/orca-runtime-files-search.test.ts b/src/main/runtime/orca-runtime-files-search.test.ts index 87f7d566b78..2cde42a8d2f 100644 --- a/src/main/runtime/orca-runtime-files-search.test.ts +++ b/src/main/runtime/orca-runtime-files-search.test.ts @@ -3,7 +3,6 @@ import { EventEmitter } from 'node:events' import { bundledRipgrepCommandMock, getLocalGitOptionsForRegisteredWorktreeMock, - getSshFilesystemProviderMock, resolveAuthorizedPathMock, wslAwareSpawnMock } from './orca-runtime-files-mock-registry' @@ -34,10 +33,6 @@ vi.mock( vi.mock('../ripgrep/bundled-ripgrep-path', async () => (await import('./orca-runtime-files-mock-registry')).bundledRipgrepPathModuleMock() ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) type MockRuntimeSearchChild = EventEmitter & { stdout: EventEmitter & { setEncoding: ReturnType } @@ -63,18 +58,6 @@ async function flushRuntimeSearchMicrotasks(): Promise { describe('RuntimeFileCommands', () => { useRuntimeFileCommandsLifecycle() - it('forwards cancellation across a nested SSH route', async () => { - const controller = new AbortController() - const search = vi.fn().mockResolvedValue({ files: [], totalMatches: 0, truncated: false }) - getSshFilesystemProviderMock.mockReturnValue({ search }) - const { commands } = createRuntimeFileCommands({ hostId: 'ssh:ssh-1' }) - await commands.searchRuntimeFiles('id:wt-1', { query: 'needle' }, { signal: controller.signal }) - expect(search).toHaveBeenCalledWith( - { rootPath: '/repo', query: 'needle' }, - { signal: controller.signal } - ) - }) - it('keeps concurrent runtime clients on the same root independent during cancellation', async () => { const { commands } = createRuntimeFileCommands() const firstChild = createRuntimeSearchChild() @@ -147,26 +130,6 @@ describe('RuntimeFileCommands', () => { ) }) - it('keeps byte-budgeted legacy listings count-bounded across an SSH hop', async () => { - const listFiles = vi.fn().mockResolvedValue(['src/index.ts']) - getSshFilesystemProviderMock.mockReturnValue({ listFiles }) - const { commands } = createRuntimeFileCommands({ - resolveRuntimeFileTarget: vi.fn(async () => ({ - worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, - executionHostId: 'ssh:ssh-1' - })) - }) - - await expect(commands.listRuntimeFiles('id:wt-1', { maxContentBytes: 1024 })).resolves.toEqual([ - 'src/index.ts' - ]) - expect(listFiles).toHaveBeenCalledWith('/repo', { - excludePaths: undefined, - maxResults: 20_001, - signal: undefined - }) - }) - it('settles and detaches runtime rg searches when timeout kill is ignored', async () => { const resolveRuntimeFileTarget = vi.fn(async () => ({ worktree: { @@ -327,30 +290,4 @@ describe('RuntimeFileCommands', () => { }) ) }) - - it('keeps legacy SSH Quick Open replies within the frame-sized result bound', async () => { - const resolveRuntimeFileTarget = vi.fn(async () => ({ - worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, - executionHostId: 'ssh:ssh-1' - })) - const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) - const listFiles = vi.fn(async () => ['src/target.ts']) - getSshFilesystemProviderMock.mockReturnValue({ - supportsQuickOpenSearch: vi.fn(async () => false), - listFiles - }) - - await expect(commands.searchQuickOpenFilePaths('id:wt-1', 'target', 32)).resolves.toMatchObject( - { - files: [{ relativePath: 'src/target.ts' }], - totalCount: 1, - truncated: false - } - ) - expect(listFiles).toHaveBeenCalledWith('/repo', { - excludePaths: undefined, - maxResults: 32, - signal: undefined - }) - }) }) diff --git a/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts b/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts deleted file mode 100644 index edb546b0396..00000000000 --- a/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { createHash } from 'node:crypto' -import { describe, expect, it, vi } from 'vitest' -import { enoent, openMock, resolveAuthorizedPathMock } from './orca-runtime-files-mock-registry' -import { - createRuntimeFileCommands, - useRuntimeFileCommandsLifecycle -} from './orca-runtime-files-test-harness' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { FileRangeReadUnsupportedError } from '../providers/types' -import { MAX_FILE_RANGE_READ_BYTES } from '../../shared/file-range-read' -import { BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES } from '../../shared/browser-client-file-channel-protocol' -import type { RuntimeFileCommands } from './orca-runtime-files' - -vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock()) -vi.mock('fs/promises', async () => - (await import('./orca-runtime-files-mock-registry')).fsPromisesModuleMock() -) -vi.mock( - './file-watcher-host', - async () => (await import('./orca-runtime-files-mock-registry')).fileWatcherHostMock -) -vi.mock('../ipc/filesystem-auth', async () => - (await import('./orca-runtime-files-mock-registry')).filesystemAuthModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() -) -vi.mock( - '../ipc/local-worktree-runtime-options', - async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock -) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) - -/** - * LCG bytes, whose period is far past any fixture here. An index-derived pattern repeats on a - * power-of-two stride that divides the chunk and window sizes, which makes a re-read of the wrong - * window compare equal to the right one. - */ -function remoteFileBytes(size: number): Buffer { - const bytes = Buffer.alloc(size) - let state = 0x2f6e2b1 - for (let index = 0; index < size; index += 1) { - state = (state * 1664525 + 1013904223) >>> 0 - bytes[index] = state >>> 24 - } - return bytes -} - -function sshCommands() { - return createRuntimeFileCommands({ - path: '/remote/repo', - resolveRuntimeFileTarget: vi.fn(async () => ({ - worktree: { id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' }, - executionHostId: 'ssh:ssh-1' - })) - }).commands -} - -function installSshFile( - contents: Buffer, - overrides?: { readFileRange?: unknown; stat?: unknown } -): { readFileRange: ReturnType; stat: ReturnType } { - const readFileRange = vi.fn(async (_filePath: string, position: number, length: number) => { - const bytes = contents.subarray(position, position + length) - return { bytes, bytesRead: bytes.byteLength } - }) - const stat = vi.fn(async () => ({ size: contents.byteLength, type: 'file' as const, mtime: 0 })) - const provider = { - stat: overrides?.stat ?? stat, - ...(overrides && 'readFileRange' in overrides - ? { readFileRange: overrides.readFileRange } - : { readFileRange }) - } - vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never) - return { readFileRange, stat } -} - -/** Mirrors the `browser.upload` staging loop in browser-client-upload-transfer. */ -async function drainUploadChunks( - commands: RuntimeFileCommands, - relativePath: string -): Promise { - const parts: Buffer[] = [] - let offset = 0 - for (let iterations = 0; ; iterations += 1) { - expect(iterations).toBeLessThan(64) - const chunk = await commands.readFileExplorerChunk( - 'id:wt-1', - relativePath, - offset, - BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES - ) - const payload = Buffer.from(chunk.contentBase64, 'base64') - // The real loop rejects a chunk whose count disagrees with its payload before staging it. - expect(payload.byteLength).toBe(chunk.bytesRead) - parts.push(payload) - offset += chunk.bytesRead - if (chunk.eof) { - return Buffer.concat(parts) - } - expect(chunk.bytesRead).toBeGreaterThan(0) - } -} - -/** Digest compare: a failed `toEqual` on a few-hundred-KB Buffer spends minutes rendering a diff. */ -function expectSameBytes(actual: Buffer, expected: Buffer): void { - const digest = (bytes: Buffer) => ({ - byteLength: bytes.byteLength, - sha256: createHash('sha256').update(bytes).digest('hex') - }) - expect(digest(actual)).toEqual(digest(expected)) -} - -/** The clamp and the cap together promise every request lands inside the statted file. */ -function expectRequestsWithinFile(readFileRange: ReturnType, fileSize: number): void { - expect(readFileRange.mock.calls.length).toBeGreaterThan(0) - for (const [, position, length] of readFileRange.mock.calls) { - expect(length).toBeLessThanOrEqual(MAX_FILE_RANGE_READ_BYTES) - expect(position + length).toBeLessThanOrEqual(fileSize) - } -} - -// A size that is not a multiple of either the chunk or the window, so a page served from the wrong -// offset cannot land on an identical-looking boundary. -const REMOTE_FILE_BYTES = BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES * 3 + 4321 -// files.readChunk window the renderer's remote download loop uses (REMOTE_DOWNLOAD_CHUNK_BYTES). -const REMOTE_DOWNLOAD_WINDOW_BYTES = 384 * 1024 - -describe('RuntimeFileCommands.readFileExplorerChunk over SSH', () => { - useRuntimeFileCommandsLifecycle() - - it('reassembles a multi-chunk SSH-workspace file byte-identically', async () => { - const contents = remoteFileBytes(REMOTE_FILE_BYTES) - const { readFileRange } = installSshFile(contents) - - expectSameBytes(await drainUploadChunks(sshCommands(), 'docs/report.pdf'), contents) - expect(readFileRange).toHaveBeenCalledWith( - '/remote/repo/docs/report.pdf', - 0, - expect.any(Number) - ) - expectRequestsWithinFile(readFileRange, contents.byteLength) - }) - - it('ends on the chunk boundary when the file size is an exact chunk multiple', async () => { - const contents = remoteFileBytes(BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES * 2) - installSshFile(contents) - const commands = sshCommands() - - const first = await commands.readFileExplorerChunk( - 'id:wt-1', - 'archive.zip', - 0, - BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES - ) - const second = await commands.readFileExplorerChunk( - 'id:wt-1', - 'archive.zip', - BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES, - BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES - ) - - expect(first.eof).toBe(false) - expect(second.bytesRead).toBe(BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES) - expect(second.eof).toBe(true) - expectSameBytes( - Buffer.concat([ - Buffer.from(first.contentBase64, 'base64'), - Buffer.from(second.contentBase64, 'base64') - ]), - contents - ) - }) - - it('pages a window wider than the host range cap instead of asking for an over-cap read', async () => { - const contents = remoteFileBytes(REMOTE_FILE_BYTES) - const { readFileRange } = installSshFile(contents) - - const chunk = await sshCommands().readFileExplorerChunk( - 'id:wt-1', - 'big.bin', - 0, - REMOTE_FILE_BYTES - ) - - expectSameBytes(Buffer.from(chunk.contentBase64, 'base64'), contents) - expect(chunk.bytesRead).toBe(REMOTE_FILE_BYTES) - expect(chunk.eof).toBe(true) - expect(readFileRange.mock.calls.map((call) => [call[1], call[2]])).toEqual([ - [0, MAX_FILE_RANGE_READ_BYTES], - [MAX_FILE_RANGE_READ_BYTES, REMOTE_FILE_BYTES - MAX_FILE_RANGE_READ_BYTES] - ]) - }) - - // The upload chunk (128 KiB) never pages; the renderer's 384 KiB download window is the caller - // that does, and it reaches SSH worktrees only because this seam stopped throwing. - it('pages the renderer download window into one capped read and one remainder', async () => { - const contents = remoteFileBytes(REMOTE_FILE_BYTES) - const { readFileRange } = installSshFile(contents) - - const chunk = await sshCommands().readFileExplorerChunk( - 'id:wt-1', - 'big.bin', - 0, - REMOTE_DOWNLOAD_WINDOW_BYTES - ) - - expectSameBytes( - Buffer.from(chunk.contentBase64, 'base64'), - contents.subarray(0, REMOTE_DOWNLOAD_WINDOW_BYTES) - ) - expect(chunk.eof).toBe(false) - expect(readFileRange.mock.calls.map((call) => [call[1], call[2]])).toEqual([ - [0, MAX_FILE_RANGE_READ_BYTES], - [MAX_FILE_RANGE_READ_BYTES, REMOTE_DOWNLOAD_WINDOW_BYTES - MAX_FILE_RANGE_READ_BYTES] - ]) - }) - - it('clamps a window that overshoots EOF instead of requesting past the file', async () => { - const contents = remoteFileBytes(MAX_FILE_RANGE_READ_BYTES + 4321) - const { readFileRange } = installSshFile(contents) - - const chunk = await sshCommands().readFileExplorerChunk( - 'id:wt-1', - 'big.bin', - 0, - REMOTE_DOWNLOAD_WINDOW_BYTES - ) - - expectSameBytes(Buffer.from(chunk.contentBase64, 'base64'), contents) - expect(chunk.eof).toBe(true) - expectRequestsWithinFile(readFileRange, contents.byteLength) - }) - - it('never reads past the requested window', async () => { - const contents = remoteFileBytes(4096) - installSshFile(contents) - - const chunk = await sshCommands().readFileExplorerChunk('id:wt-1', 'small.bin', 1000, 500) - - expect(chunk.bytesRead).toBe(500) - expect(Buffer.from(chunk.contentBase64, 'base64')).toEqual(contents.subarray(1000, 1500)) - expect(chunk.eof).toBe(false) - }) - - // Not a stall guarantee: the caller's next chunk re-stats the now-smaller file and ends the - // transfer short, exactly as the local branch does. Only the single chunk's flag is pinned here. - it('stops on a window the host could not fill and reports it against the stale size', async () => { - const contents = remoteFileBytes(4096) - installSshFile(contents, { - readFileRange: vi.fn(async () => ({ bytes: contents.subarray(0, 10), bytesRead: 10 })) - }) - - const chunk = await sshCommands().readFileExplorerChunk('id:wt-1', 'short.bin', 0, 4096) - - expect(chunk.bytesRead).toBe(10) - expect(chunk.eof).toBe(false) - }) - - it('fails loudly when the SSH host cannot serve ranged reads', async () => { - installSshFile(remoteFileBytes(4096), { - readFileRange: vi.fn(async () => { - throw new FileRangeReadUnsupportedError() - }) - }) - - await expect( - sshCommands().readFileExplorerChunk('id:wt-1', 'docs/report.pdf', 0, 1024) - ).rejects.toThrow('ranged file reads') - }) - - it('fails loudly when the SSH provider has no ranged-read method at all', async () => { - installSshFile(remoteFileBytes(4096), { readFileRange: undefined }) - - await expect( - sshCommands().readFileExplorerChunk('id:wt-1', 'docs/report.pdf', 0, 1024) - ).rejects.toThrow('ranged file reads') - }) - - it('fails loudly when the remote file is missing', async () => { - const { readFileRange } = installSshFile(remoteFileBytes(4096), { - stat: vi.fn(async () => { - throw enoent() - }) - }) - - await expect( - sshCommands().readFileExplorerChunk('id:wt-1', 'docs/missing.pdf', 0, 1024) - ).rejects.toThrow('ENOENT') - expect(readFileRange).not.toHaveBeenCalled() - }) - - it('refuses a remote directory before reading', async () => { - const { readFileRange } = installSshFile(remoteFileBytes(0), { - stat: vi.fn(async () => ({ size: 0, type: 'directory' as const, mtime: 0 })) - }) - - await expect(sshCommands().readFileExplorerChunk('id:wt-1', 'docs', 0, 1024)).rejects.toThrow( - 'Cannot download a directory' - ) - expect(readFileRange).not.toHaveBeenCalled() - }) - - it('still serves a non-SSH worktree from the local file handle', async () => { - const contents = remoteFileBytes(2048) - const { commands } = createRuntimeFileCommands() - resolveAuthorizedPathMock.mockResolvedValue('/repo/local.bin') - const close = vi.fn(async () => undefined) - openMock.mockResolvedValue({ - stat: vi.fn(async () => ({ isFile: () => true, size: contents.byteLength })), - read: vi.fn( - async (buffer: Buffer, bufferOffset: number, length: number, position: number) => { - const bytesRead = contents.copy(buffer, bufferOffset, position, position + length) - return { bytesRead } - } - ), - close - }) - - const chunk = await commands.readFileExplorerChunk('id:wt-1', 'local.bin', 0, 1024) - - expect(Buffer.from(chunk.contentBase64, 'base64')).toEqual(contents.subarray(0, 1024)) - expect(chunk.eof).toBe(false) - expect(close).toHaveBeenCalled() - expect(getSshFilesystemProvider).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts b/src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts index 7a90429cfd1..1495cb1329b 100644 --- a/src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts @@ -3,7 +3,6 @@ import { link, mkdtemp, readFile, realpath, symlink, writeFile } from 'node:fs/p import { tmpdir } from 'node:os' import { join } from 'node:path' import { enoent, resolveAuthorizedPathMock, statMock } from './orca-runtime-files-mock-registry' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { createRuntimeFileCommands, useRuntimeFileCommandsLifecycle @@ -32,10 +31,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) describe('RuntimeFileCommands', () => { useRuntimeFileCommandsLifecycle() @@ -376,77 +371,9 @@ describe('RuntimeFileCommands', () => { expect(statMock).not.toHaveBeenCalled() }) - it('uses the canonical remote temp artifact path for the exact grant', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 }) - const realpath = vi.fn(async (p: string) => - p === '/tmp/link-result.json' ? '/tmp/result.json' : p - ) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) - - const result = await resolveTerminalArtifactPath(commands, '/tmp/link-result.json') - - expect(stat).toHaveBeenCalledWith('/tmp/result.json') - expect(result).toMatchObject({ - relativePath: null, - absolutePath: '/tmp/result.json', - exists: true, - openTarget: { - kind: 'absolute-file', - provider: 'ssh', - absolutePath: '/tmp/result.json' - } - }) - }) - - it('does not grant hard-linked remote temp artifacts', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3, nlink: 2 }) - const realpath = vi.fn(async (p: string) => p) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) - - const result = await resolveTerminalArtifactPath(commands, '/tmp/result.json') - - expect(result).toMatchObject({ - relativePath: null, - absolutePath: '/tmp/result.json', - exists: false - }) - expect(result.openTarget).toBeUndefined() - }) - - it('allows canonical remote macOS private temp artifacts', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 }) - const realpath = vi.fn(async (p: string) => p) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) - - const result = await resolveTerminalArtifactPath(commands, '/private/tmp/result.json') - - expect(stat).toHaveBeenCalledWith('/private/tmp/result.json') - expect(result).toMatchObject({ - relativePath: null, - absolutePath: '/private/tmp/result.json', - exists: true, - openTarget: { - kind: 'absolute-file', - provider: 'ssh', - absolutePath: '/private/tmp/result.json' - } - }) - }) - it('does not grant remote temp artifacts that resolve outside allowed temp roots', async () => { const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi.fn() - const realpath = vi.fn(async (p: string) => - p === '/tmp/link-result.json' ? '/home/me/.ssh/config' : p - ) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) const result = await commands.resolveTerminalPath( 'id:wt-1', @@ -462,7 +389,7 @@ describe('RuntimeFileCommands', () => { exists: false, isDirectory: false }) - expect(stat).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() }) }) }) diff --git a/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts b/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts index 9817ee9b48d..68028cbe924 100644 --- a/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts @@ -11,7 +11,6 @@ import { resolveTerminalArtifactPath, useTerminalArtifactTempFiles } from './orca-runtime-files-terminal-artifact-fixtures' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES } from './orca-runtime-files' import type { RuntimeFileCommands } from './orca-runtime-files' @@ -34,10 +33,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) describe('RuntimeFileCommands', () => { useRuntimeFileCommandsLifecycle() @@ -45,42 +40,6 @@ describe('RuntimeFileCommands', () => { describe('resolveTerminalPath', () => { const { tempFile } = useTerminalArtifactTempFiles() - function createRemoteTerminalArtifactGrantFixture( - artifactPath = '/tmp/result.json', - nativeChat = false - ) { - const { commands, store } = createRuntimeFileCommands({ - path: '/repo', - ...(nativeChat ? { hasRecentNativeChatOutputPath: vi.fn(() => true) } : {}) - }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - let realArtifactPath = artifactPath - let artifactStat = { type: 'file', size: 11, mtime: 3 } - const stat = vi.fn(async () => artifactStat) - const readTerminalArtifact = vi - .fn() - .mockResolvedValue({ content: '{"ok":true}', isBinary: false }) - const writeTerminalArtifact = vi.fn().mockResolvedValue({ type: 'file', size: 12, mtime: 4 }) - const realpath = vi.fn(async (p: string) => (p === artifactPath ? realArtifactPath : p)) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat, - readTerminalArtifact, - realpath, - writeTerminalArtifact - } as never) - return { - commands, - readTerminalArtifact, - writeTerminalArtifact, - moveArtifactTarget: (nextPath: string) => { - realArtifactPath = nextPath - }, - replaceArtifact: () => { - artifactStat = { type: 'file', size: 12, mtime: 4 } - } - } - } - function resolveRemoteNativeChatArtifact(commands: RuntimeFileCommands, artifactPath: string) { return commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a', null, true, { tabId: 'tab-1', @@ -405,129 +364,16 @@ describe('RuntimeFileCommands', () => { await expect(readFile(artifactPath, 'utf8')).resolves.toBe('%PDF text-looking bytes') }) - it('rejects remote binary terminal artifact writes before changing the file', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi - .fn() - .mockResolvedValue({ type: 'file', size: 4, mtimeMs: 3, isDirectory: () => false }) - const writeTerminalArtifact = vi.fn().mockRejectedValue(new Error('binary_file')) - const realpath = vi.fn(async (p: string) => p) - const writeFile = vi.fn() - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - stat, - realpath, - writeFile, - writeTerminalArtifact - } as never) - - const result = await resolveTerminalArtifactPath(commands, '/tmp/result.txt') - const grantId = result.openTarget?.kind === 'absolute-file' ? result.openTarget.grantId : '' - - await expect( - commands.writeTerminalArtifactFile( - 'id:wt-1', - grantId, - '/tmp/result.txt', - 'not binary anymore', - 'client-a' - ) - ).rejects.toThrow('binary_file') - expect(writeFile).not.toHaveBeenCalled() - expect(writeTerminalArtifact).toHaveBeenCalled() - }) - it('refuses a native-chat artifact on an SSH target as not served', async () => { - const { commands, readTerminalArtifact } = createRemoteTerminalArtifactGrantFixture( - '/home/me/report.json', - true - ) + const { commands, store } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath: vi.fn(() => true) + }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) await expect( resolveRemoteNativeChatArtifact(commands, '/home/me/report.json') ).rejects.toThrow(SshHostNotServedError) - expect(readTerminalArtifact).not.toHaveBeenCalled() - }) - - it('rejects remote terminal artifact reads when a grant no longer resolves to the granted path', async () => { - const { commands, readTerminalArtifact, moveArtifactTarget } = - createRemoteTerminalArtifactGrantFixture() - const result = await resolveTerminalArtifactPath(commands, '/tmp/result.json') - const target = absoluteFileTarget(result) - - moveArtifactTarget('/home/me/.ssh/config') - - await expect( - commands.readTerminalArtifactFile( - 'id:wt-1', - target.grantId, - target.absolutePath, - 'client-a' - ) - ).rejects.toThrow('terminal_file_grant_stale') - expect(readTerminalArtifact).not.toHaveBeenCalled() - }) - - it('rejects remote terminal artifact previews when a grant no longer resolves to the granted path', async () => { - const { commands, readTerminalArtifact, moveArtifactTarget } = - createRemoteTerminalArtifactGrantFixture('/tmp/result.png') - const result = await resolveTerminalArtifactPath(commands, '/tmp/result.png') - const target = absoluteFileTarget(result) - - moveArtifactTarget('/tmp/other.png') - - await expect( - commands.readTerminalArtifactPreview( - 'id:wt-1', - target.grantId, - target.absolutePath, - 'client-a' - ) - ).rejects.toThrow('terminal_file_grant_stale') - expect(readTerminalArtifact).not.toHaveBeenCalled() - }) - - it('rejects additive remote terminal preview fields beyond the request budget', async () => { - const { commands, readTerminalArtifact } = - createRemoteTerminalArtifactGrantFixture('/tmp/result.png') - const result = await resolveTerminalArtifactPath(commands, '/tmp/result.png') - const target = absoluteFileTarget(result) - readTerminalArtifact.mockResolvedValue({ - content: 'a', - isBinary: true, - futureMetadata: 'x'.repeat(128) - }) - - await expect( - commands.readTerminalArtifactPreview( - 'id:wt-1', - target.grantId, - target.absolutePath, - 'client-a', - 128 - ) - ).rejects.toThrow('file_too_large') - }) - - it('rejects remote terminal artifact writes when a grant no longer resolves to the granted path', async () => { - const { commands, readTerminalArtifact, writeTerminalArtifact, moveArtifactTarget } = - createRemoteTerminalArtifactGrantFixture() - const result = await resolveTerminalArtifactPath(commands, '/tmp/result.json') - const target = absoluteFileTarget(result) - - moveArtifactTarget('/home/me/.ssh/config') - - await expect( - commands.writeTerminalArtifactFile( - 'id:wt-1', - target.grantId, - target.absolutePath, - '{"ok":false}', - 'client-a' - ) - ).rejects.toThrow('terminal_file_grant_stale') - expect(readTerminalArtifact).not.toHaveBeenCalled() - expect(writeTerminalArtifact).not.toHaveBeenCalled() }) }) }) diff --git a/src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts b/src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts index f0bd96d9001..ef0ff4a2b9b 100644 --- a/src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts @@ -1,7 +1,7 @@ +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { describe, expect, it, vi } from 'vitest' import { realpath } from 'node:fs/promises' import { openMock, resolveAuthorizedPathMock, statMock } from './orca-runtime-files-mock-registry' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { createRuntimeFileCommands, useRuntimeFileCommandsLifecycle @@ -30,10 +30,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) describe('RuntimeFileCommands', () => { useRuntimeFileCommandsLifecycle() @@ -182,7 +178,7 @@ describe('RuntimeFileCommands', () => { }) }) - it('opens host-qualified remote POSIX terminal links when the source terminal verified the host', async () => { + it('refuses host-qualified terminal links on an SSH workspace instead of reading this machine', async () => { const resolveTerminalFileUriHostname = vi.fn(() => 'remote-host') const hasRecentTerminalOutputPath = vi.fn(() => true) const { commands, store } = createRuntimeFileCommands({ @@ -191,69 +187,25 @@ describe('RuntimeFileCommands', () => { hasRecentTerminalOutputPath }) store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 }) - const realpath = vi.fn(async (p: string) => p) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) - const result = await resolveTerminalArtifactPath(commands, '//remote-host/tmp/result.json') - - expect(resolveTerminalFileUriHostname).toHaveBeenCalledWith('term-1') - expect(hasRecentTerminalOutputPath).toHaveBeenCalledWith( - 'term-1', - '//remote-host/tmp/result.json', - '/tmp/result.json' - ) - expect(stat).toHaveBeenCalledWith('/tmp/result.json') - expect(result).toMatchObject({ - relativePath: null, - absolutePath: '/tmp/result.json', - exists: true, - openTarget: { - kind: 'absolute-file', - provider: 'ssh', - absolutePath: '/tmp/result.json' - } - }) - }) - - it('opens host-qualified Windows SSH worktree file URLs with a drive path', async () => { - const resolveTerminalFileUriHostname = vi.fn(() => 'remote-host') - const { commands, store } = createRuntimeFileCommands({ - path: 'C:/Users/me/repo', - resolveTerminalFileUriHostname - }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 }) - const realpath = vi.fn(async (p: string) => p) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) - - const result = await commands.resolveTerminalPath( - 'id:wt-1', - '//remote-host/C:/Users/me/repo/src/app.ts', - null, - 'client-a', - 'term-1' - ) - - expect(result).toMatchObject({ - relativePath: 'src/app.ts', - absolutePath: 'C:/Users/me/repo/src/app.ts', - exists: true, - openTarget: { - kind: 'worktree-file', - provider: 'ssh', - relativePath: 'src/app.ts', - absolutePath: 'C:/Users/me/repo/src/app.ts' - } - }) + await expect( + resolveTerminalArtifactPath(commands, '//remote-host/tmp/result.json') + ).rejects.toThrow(SshHostNotServedError) + await expect( + commands.resolveTerminalPath( + 'id:wt-1', + '//remote-host/C:/Users/me/repo/src/app.ts', + null, + 'client-a', + 'term-1' + ) + ).rejects.toThrow(SshHostNotServedError) + expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled() }) it('rejects host-qualified remote POSIX terminal links without a verified host match', async () => { const { commands, store } = createRuntimeFileCommands({ path: '/home/me/repo' }) store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 }) - const realpath = vi.fn(async (p: string) => p) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) const result = await commands.resolveTerminalPath( 'id:wt-1', @@ -262,7 +214,7 @@ describe('RuntimeFileCommands', () => { 'client-a' ) - expect(stat).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() expect(result).toEqual({ worktree: 'wt-1', relativePath: null, diff --git a/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts b/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts index 2d0766ffc50..4256c98fb3d 100644 --- a/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts @@ -1,3 +1,4 @@ +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { describe, expect, it, vi } from 'vitest' import { resolveAuthorizedPathMock, statMock } from './orca-runtime-files-mock-registry' import { @@ -8,7 +9,6 @@ import { resolveTerminalArtifactPath, statAsFile } from './orca-runtime-files-terminal-artifact-fixtures' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock()) vi.mock('fs/promises', async () => @@ -28,10 +28,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) describe('RuntimeFileCommands', () => { useRuntimeFileCommandsLifecycle() @@ -173,99 +169,14 @@ describe('RuntimeFileCommands', () => { expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled() }) - it('stats a sibling SSH workspace through its owning provider', async () => { - const sibling = { - id: 'wt-2', - repoId: 'repo-2', - path: '/sibling', - hostId: 'ssh:ssh-1', - git: { - path: '/sibling', - head: '', - branch: '', - isBare: false, - isMainWorktree: true - } - } - const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ - worktree: sibling, - executionHostId: 'ssh:ssh-1', - relativePath: 'docs/readme.md' - })) - const { commands, store } = createRuntimeFileCommands({ - path: '/repo', - resolveKnownWorkspaceFileTarget - }) + it('refuses an SSH workspace path instead of stating this machine', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const remoteStat = vi.fn().mockResolvedValue({ type: 'file', size: 12, mtime: 3 }) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat: remoteStat } as never) - const result = await commands.resolveTerminalPath( - 'id:wt-1', - '/sibling/docs/readme.md', - null, - undefined, - null, - true + await expect(commands.resolveTerminalPath('id:wt-1', 'src/app.ts')).rejects.toThrow( + SshHostNotServedError ) - - expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith( - '/sibling/docs/readme.md', - 'ssh:ssh-1' - ) - expect(remoteStat).toHaveBeenCalledWith('/sibling/docs/readme.md') expect(statMock).not.toHaveBeenCalled() - expect(result).toMatchObject({ - worktree: 'wt-2', - relativePath: 'docs/readme.md', - exists: true, - openTarget: { provider: 'ssh' } - }) - }) - - it('stats an exact SSH sibling root as a directory through its owning provider', async () => { - const sibling = { - id: 'wt-2', - repoId: 'repo-2', - path: '/sibling', - hostId: 'ssh:ssh-1', - git: { - path: '/sibling', - head: '', - branch: '', - isBare: false, - isMainWorktree: true - } - } - const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ - worktree: sibling, - executionHostId: 'ssh:ssh-1', - relativePath: '' - })) - const hasRecentTerminalOutputPath = vi.fn(() => true) - const { commands, store } = createRuntimeFileCommands({ - path: '/repo', - resolveKnownWorkspaceFileTarget, - hasRecentTerminalOutputPath - }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const remoteStat = vi.fn().mockResolvedValue({ type: 'directory', size: 0, mtime: 3 }) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat: remoteStat } as never) - - const result = await resolveTerminalArtifactPath(commands, '/sibling', null, 'client-a', true) - - expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith('/sibling', 'ssh:ssh-1') - expect(remoteStat).toHaveBeenCalledWith('/sibling') - expect(statMock).not.toHaveBeenCalled() - expect(result).toEqual({ - worktree: 'wt-2', - relativePath: '', - absolutePath: '/sibling', - exists: true, - isDirectory: true, - openTarget: undefined - }) - expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled() }) // The host was `runtime:env-a` until this process stopped dispatching runtime hosts at all @@ -445,13 +356,11 @@ describe('RuntimeFileCommands', () => { it('does not expand ~/ on a remote worktree (home is unknown)', async () => { const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi.fn() - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat } as never) const result = await commands.resolveTerminalPath('id:wt-1', '~/notes.md') expect(result).toMatchObject({ relativePath: null, exists: false }) - expect(stat).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() }) it('still refuses a native-chat ~/ path on a remote worktree', async () => { @@ -475,27 +384,5 @@ describe('RuntimeFileCommands', () => { expect(result).toMatchObject({ relativePath: null, exists: false }) expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled() }) - - it('reports a missing remote file as not existing', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi.fn().mockRejectedValue(new Error('ENOENT: no such file')) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat } as never) - - const result = await commands.resolveTerminalPath('id:wt-1', 'src/missing.ts') - - expect(result).toMatchObject({ relativePath: 'src/missing.ts', exists: false }) - }) - - it('rethrows a remote transport error instead of reporting not-found', async () => { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const stat = vi.fn().mockRejectedValue(new Error('Remote connection dropped')) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat } as never) - - await expect(commands.resolveTerminalPath('id:wt-1', 'src/x.ts')).rejects.toThrow( - 'Remote connection dropped' - ) - }) }) }) diff --git a/src/main/runtime/orca-runtime-files-watch-host-scope.test.ts b/src/main/runtime/orca-runtime-files-watch-host-scope.test.ts index e9ebb7ae124..a232c690df3 100644 --- a/src/main/runtime/orca-runtime-files-watch-host-scope.test.ts +++ b/src/main/runtime/orca-runtime-files-watch-host-scope.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, vi } from 'vitest' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { EventEmitter } from 'node:events' -import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { - getSshFilesystemProviderMock, resolveAuthorizedPathMock, statMock, watchInWatcherProcessMock, @@ -12,7 +11,6 @@ import { createRuntimeFileCommands, useRuntimeFileCommandsLifecycle } from './orca-runtime-files-test-harness' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { awaitRuntimeFileWatcherUnsubscribes } from './orca-runtime-files' vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock()) @@ -33,10 +31,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) describe('RuntimeFileCommands', () => { useRuntimeFileCommandsLifecycle() @@ -102,80 +96,14 @@ describe('RuntimeFileCommands', () => { expect(dispose).toHaveBeenCalledTimes(1) }) - it('keeps SSH runtime watches on the remote filesystem provider', async () => { - const remoteDispose = vi.fn() - const providerWatch = vi.fn( - (_rootPath: string, _callback: (events: FsChangeEvent[]) => void) => remoteDispose - ) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ watch: providerWatch } as never) + it('refuses an SSH runtime watch without watching this machine', async () => { const { commands, store } = createRuntimeFileCommands({ path: '/remote/repo' }) store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - const onEvents = vi.fn() - const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents) - - expect(providerWatch).toHaveBeenCalledWith('/remote/repo', expect.any(Function), { - signal: undefined, - onTerminalError: expect.any(Function) - }) - const callback = providerWatch.mock.calls[0]?.[1] - callback?.([{ kind: 'update', absolutePath: '/remote/repo/current.ts' }]) - expect(onEvents).toHaveBeenCalledWith([ - { kind: 'update', absolutePath: '/remote/repo/current.ts' } - ]) + await expect(commands.watchFileExplorer('id:wt-1', vi.fn())).rejects.toThrow( + SshHostNotServedError + ) expect(watchInWatcherProcessMock).not.toHaveBeenCalled() - await unsubscribe() - expect(remoteDispose).toHaveBeenCalledTimes(1) - }) - - it('indexes SSH runtime watches so remote deletion can await them', async () => { - let resolveDispose: () => void = () => {} - const remoteDispose = vi.fn( - () => - new Promise((resolve) => { - resolveDispose = resolve - }) - ) - vi.mocked(getSshFilesystemProvider).mockReturnValue({ - watch: vi.fn(() => remoteDispose) - } as never) - const { commands, store } = createRuntimeFileCommands({ path: '/remote/repo' }) - store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - await commands.watchFileExplorer('id:wt-1', vi.fn()) - - let closed = false - const close = commands.closeFileExplorerWatchersForPath('/remote/repo', 'ssh-1').then(() => { - closed = true - }) - await Promise.resolve() - expect(remoteDispose).toHaveBeenCalledTimes(1) - expect(closed).toBe(false) - - resolveDispose() - await close - }) - - it('scopes same-path runtime watcher teardown to its SSH execution host', async () => { - const firstDispose = vi.fn() - const secondDispose = vi.fn() - const firstProvider = { watch: vi.fn(() => firstDispose) } - const secondProvider = { watch: vi.fn(() => secondDispose) } - getSshFilesystemProviderMock.mockImplementation((connectionId) => - connectionId === 'ssh-1' ? firstProvider : secondProvider - ) - const first = createRuntimeFileCommands({ path: '/same/repo' }) - const second = createRuntimeFileCommands({ path: '/same/repo' }) - first.store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - second.store.getRepo.mockReturnValue({ connectionId: 'ssh-2' }) - - await first.commands.watchFileExplorer('id:wt-1', vi.fn()) - await second.commands.watchFileExplorer('id:wt-1', vi.fn()) - await first.commands.closeFileExplorerWatchersForPath('/same/repo', 'ssh-1') - - expect(firstDispose).toHaveBeenCalledTimes(1) - expect(secondDispose).not.toHaveBeenCalled() - - await second.commands.closeFileExplorerWatchersForPath('/same/repo', 'ssh-2') - expect(secondDispose).toHaveBeenCalledTimes(1) + expect(watchMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/orca-runtime-files-watch.test.ts b/src/main/runtime/orca-runtime-files-watch.test.ts index 9fa150ba9a7..2e341df1e04 100644 --- a/src/main/runtime/orca-runtime-files-watch.test.ts +++ b/src/main/runtime/orca-runtime-files-watch.test.ts @@ -4,7 +4,6 @@ import type * as Fs from 'node:fs' import type * as FsPromises from 'node:fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' import type { FsChangeEvent } from '../../shared/filesystem-entry-types' -import type { IFilesystemProvider } from '../providers/types' import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate' @@ -13,15 +12,13 @@ const { statMock, watchMock, watchInWatcherProcessMock, - closeWatcherInWatcherProcessMock, - getSshFilesystemProviderMock + closeWatcherInWatcherProcessMock } = vi.hoisted(() => ({ resolveAuthorizedPathMock: vi.fn(), statMock: vi.fn(), watchMock: vi.fn(), watchInWatcherProcessMock: vi.fn(), - closeWatcherInWatcherProcessMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn() + closeWatcherInWatcherProcessMock: vi.fn() })) vi.mock('fs', async () => { @@ -54,11 +51,6 @@ vi.mock('../ipc/filesystem-auth', async () => { } }) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock, - onSshFilesystemProviderRegistered: () => () => undefined -})) - import { _getRuntimeFileWatcherReleaseCountForTests, _resetRuntimeFileWatcherLeasesForTests, @@ -109,7 +101,6 @@ describe('RuntimeFileCommands file watching', () => { watchMock.mockReset() watchInWatcherProcessMock.mockReset() closeWatcherInWatcherProcessMock.mockReset() - getSshFilesystemProviderMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -543,63 +534,4 @@ describe('RuntimeFileCommands file watching', () => { await drainPromise expect(drained).toBe(true) }) - - it.each(['abort', 'unsubscribe'] as const)( - 'forwards the SSH watch signal and fences terminal errors after %s', - async (stop) => { - const dispose = vi.fn() - const watch = vi.fn().mockResolvedValue(dispose) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const store = { getRepo: vi.fn(() => ({ connectionId: 'ssh-1' })) } - const commands = new RuntimeFileCommands({ - getRuntimeId: () => 'runtime-1', - requireStore: () => store, - resolveWorktreeSelector: vi.fn(async () => ({ - id: 'wt-1', - repoId: 'repo-1', - path: '/remote/repo' - })), - resolveRuntimeFileTarget: vi.fn(async () => ({ - worktree: { - id: 'wt-1', - repoId: 'repo-1', - path: '/remote/repo' - }, - executionHostId: 'ssh:ssh-1' - })), - resolveRuntimeGitTarget: vi.fn(), - openFile: vi.fn() - } as never) - const controller = new AbortController() - const onTerminalError = vi.fn() - - const unsubscribe = await commands.watchFileExplorer( - 'id:wt-1', - vi.fn(), - onTerminalError, - controller.signal - ) - - expect(watch).toHaveBeenCalledWith('/remote/repo', expect.any(Function), { - signal: controller.signal, - onTerminalError: expect.any(Function) - }) - const options = watch.mock.calls[0]?.[2] - expect(options?.signal).toBe(controller.signal) - const currentError = new Error('current remote watch failed') - options?.onTerminalError?.(currentError) - expect(onTerminalError).toHaveBeenCalledExactlyOnceWith(currentError) - if (stop === 'abort') { - controller.abort() - } else { - await unsubscribe() - expect(controller.signal.aborted).toBe(false) - } - options?.onTerminalError?.(new Error('late remote watch failure')) - expect(onTerminalError).toHaveBeenCalledExactlyOnceWith(currentError) - await unsubscribe() - expect(dispose).toHaveBeenCalledOnce() - expect(watchInWatcherProcessMock).not.toHaveBeenCalled() - } - ) }) diff --git a/src/main/runtime/orca-runtime-git-branch-diff.test.ts b/src/main/runtime/orca-runtime-git-branch-diff.test.ts index b27e09fefba..baded112f16 100644 --- a/src/main/runtime/orca-runtime-git-branch-diff.test.ts +++ b/src/main/runtime/orca-runtime-git-branch-diff.test.ts @@ -2,11 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) +const getBranchDiffMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) +vi.mock('../git/status', () => ({ getBranchDiff: getBranchDiffMock })) function makeWorktree(path: string): ResolvedRuntimeGitWorktree { const worktree = { @@ -27,10 +25,10 @@ function makeWorktree(path: string): ResolvedRuntimeGitWorktree { describe('RuntimeGitCommands branch diff', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() + getBranchDiffMock.mockReset() }) - it('forwards the pinned head through normalized nested SSH paths', async () => { + it('forwards the pinned head through normalized nested paths', async () => { const mergeBase = 'a'.repeat(40) const headOid = 'b'.repeat(40) const result = { @@ -40,12 +38,11 @@ describe('RuntimeGitCommands branch diff', () => { originalIsBinary: false, modifiedIsBinary: false } - const provider = { getBranchDiff: vi.fn().mockResolvedValue([result]) } - getSshGitProviderMock.mockReturnValue(provider) + getBranchDiffMock.mockResolvedValue(result) const commands = new RuntimeGitCommands({ resolveRuntimeGitTarget: async () => ({ - worktree: makeWorktree('/remote/repo'), - executionHostId: 'ssh:conn-1' + worktree: makeWorktree('/repo'), + executionHostId: 'local' }), getRuntimeSettings: () => ({}) as GlobalSettings }) @@ -59,11 +56,10 @@ describe('RuntimeGitCommands branch diff', () => { ) ).resolves.toEqual(result) - expect(provider.getBranchDiff).toHaveBeenCalledWith('/remote/repo', mergeBase, { - includePatch: true, - headOid, - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }) + expect(getBranchDiffMock).toHaveBeenCalledWith( + '/repo', + { mergeBase, headOid, filePath: 'src/file.ts', oldPath: 'src/old-file.ts' }, + { admissionTier: 'interactive' } + ) }) }) diff --git a/src/main/runtime/orca-runtime-git-diff-budget.test.ts b/src/main/runtime/orca-runtime-git-diff-budget.test.ts index 3cc401b155c..37992fc2066 100644 --- a/src/main/runtime/orca-runtime-git-diff-budget.test.ts +++ b/src/main/runtime/orca-runtime-git-diff-budget.test.ts @@ -1,5 +1,4 @@ -// Why: the cap lives in orca-runtime-git.ts so both branches of all three diff readers are covered — -// an SSH host forwards its provider's payload verbatim, so an older relay cannot be relied on to clamp it. +// Why: the cap lives in orca-runtime-git.ts so all three diff readers are covered. import { beforeEach, describe, expect, it, vi } from 'vitest' import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../shared/remote-rpc-content-budget' import type { GitDiffResult } from '../../shared/git-diff-compare-types' @@ -8,16 +7,11 @@ import type * as GitStatusModule from '../git/status' import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' const mocks = vi.hoisted(() => ({ - getSshGitProvider: vi.fn(), getDiff: vi.fn(), getBranchDiff: vi.fn(), getCommitDiff: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: mocks.getSshGitProvider -})) - vi.mock('../git/status', async () => ({ ...(await vi.importActual('../git/status')), getDiff: mocks.getDiff, @@ -44,10 +38,7 @@ function oversizedResult(): GitDiffResult { } } -function commands( - connectionId?: string, - localGitOptions?: { wslDistro: string } -): RuntimeGitCommands { +function commands(localGitOptions?: { wslDistro: string }): RuntimeGitCommands { const worktree = { id: 'wt-1', repoId: 'repo-1', @@ -57,63 +48,20 @@ function commands( return new RuntimeGitCommands({ resolveRuntimeGitTarget: async () => ({ worktree, - executionHostId: connectionId ? (`ssh:${connectionId}` as const) : ('local' as const), + executionHostId: 'local' as const, ...(localGitOptions ? { localGitOptions } : {}) }), getRuntimeSettings: () => ({}) as GlobalSettings }) } -function sshProvider(): { - getDiff: ReturnType - getBranchDiff: ReturnType - getCommitDiff: ReturnType -} { - return { - getDiff: vi.fn().mockResolvedValue(oversizedResult()), - getBranchDiff: vi.fn().mockResolvedValue([oversizedResult()]), - getCommitDiff: vi.fn().mockResolvedValue(oversizedResult()) - } -} - describe('runtime git diff transport budget', () => { beforeEach(() => { - mocks.getSshGitProvider.mockReset() mocks.getDiff.mockReset().mockResolvedValue(oversizedResult()) mocks.getBranchDiff.mockReset().mockResolvedValue(oversizedResult()) mocks.getCommitDiff.mockReset().mockResolvedValue(oversizedResult()) }) - it('caps an SSH-forwarded diff that exceeds the budget', async () => { - const provider = sshProvider() - mocks.getSshGitProvider.mockReturnValue(provider) - - await expect( - commands('conn-1').getRuntimeGitDiff( - 'id:wt-1', - 'assets/logo.png', - false, - undefined, - REMOTE_RPC_MAX_CONTENT_BYTES - ) - ).rejects.toMatchObject(TOO_LARGE) - expect(provider.getDiff).toHaveBeenCalledWith( - '/remote/repo', - 'assets/logo.png', - false, - undefined - ) - expect(mocks.getDiff).not.toHaveBeenCalled() - }) - - it('leaves an SSH-forwarded diff uncapped when no budget is supplied', async () => { - mocks.getSshGitProvider.mockReturnValue(sshProvider()) - - await expect( - commands('conn-1').getRuntimeGitDiff('id:wt-1', 'assets/logo.png', false) - ).resolves.toMatchObject({ modifiedContent: OVERSIZED_BASE64 }) - }) - it('caps a local-repo diff that exceeds the budget', async () => { await expect( commands().getRuntimeGitDiff( @@ -125,7 +73,6 @@ describe('runtime git diff transport budget', () => { ) ).rejects.toMatchObject(TOO_LARGE) expect(mocks.getDiff).toHaveBeenCalled() - expect(mocks.getSshGitProvider).not.toHaveBeenCalled() }) it('leaves a local-repo diff uncapped when no budget is supplied', async () => { @@ -134,23 +81,6 @@ describe('runtime git diff transport budget', () => { ).resolves.toMatchObject({ modifiedContent: OVERSIZED_BASE64 }) }) - it('caps an SSH-forwarded branch diff that exceeds the budget', async () => { - const provider = sshProvider() - mocks.getSshGitProvider.mockReturnValue(provider) - - await expect( - commands('conn-1').getRuntimeGitBranchDiff( - 'id:wt-1', - BRANCH_COMPARE, - 'assets/logo.png', - undefined, - REMOTE_RPC_MAX_CONTENT_BYTES - ) - ).rejects.toMatchObject(TOO_LARGE) - expect(provider.getBranchDiff).toHaveBeenCalled() - expect(mocks.getBranchDiff).not.toHaveBeenCalled() - }) - it('caps a local-repo branch diff that exceeds the budget', async () => { await expect( commands().getRuntimeGitBranchDiff( @@ -170,21 +100,6 @@ describe('runtime git diff transport budget', () => { ).resolves.toMatchObject({ modifiedContent: OVERSIZED_BASE64 }) }) - it('caps an SSH-forwarded commit diff that exceeds the budget', async () => { - const provider = sshProvider() - mocks.getSshGitProvider.mockReturnValue(provider) - - await expect( - commands('conn-1').getRuntimeGitCommitDiff( - 'id:wt-1', - COMMIT_ARGS, - REMOTE_RPC_MAX_CONTENT_BYTES - ) - ).rejects.toMatchObject(TOO_LARGE) - expect(provider.getCommitDiff).toHaveBeenCalled() - expect(mocks.getCommitDiff).not.toHaveBeenCalled() - }) - it('caps a local-repo commit diff that exceeds the budget', async () => { await expect( commands().getRuntimeGitCommitDiff('id:wt-1', COMMIT_ARGS, REMOTE_RPC_MAX_CONTENT_BYTES) @@ -201,7 +116,7 @@ describe('runtime git diff transport budget', () => { }) it('prioritizes local file diff reads without losing WSL routing', async () => { - const runtime = commands(undefined, { wslDistro: 'Ubuntu' }) + const runtime = commands({ wslDistro: 'Ubuntu' }) await runtime.getRuntimeGitBranchDiff('id:wt-1', BRANCH_COMPARE, 'assets/logo.png') await runtime.getRuntimeGitCommitDiff('id:wt-1', COMMIT_ARGS) diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index 9b27200945e..31f3a51db1d 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -6,6 +6,10 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import type * as GitStatusModule from '../git/status' import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation' import type * as PullRequestContextModule from '../text-generation/pull-request-context' +import { + SSH_HOST_NOT_SERVED_MESSAGE, + SshHostNotServedError +} from '../providers/ssh-host-not-served-error' import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' const mocks = vi.hoisted(() => ({ @@ -19,7 +23,6 @@ const mocks = vi.hoisted(() => ({ resolveCommitMessageSettings: vi.fn(), resolveHostedReviewBodyForGeneration: vi.fn(), loadPullRequestLinkedIssue: vi.fn(), - getSshGitProvider: vi.fn(), getStatus: vi.fn() })) @@ -51,10 +54,6 @@ vi.mock('../text-generation/pull-request-context', async () => ({ getPullRequestDraftContext: mocks.getPullRequestDraftContext })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: mocks.getSshGitProvider -})) - vi.mock('../source-control/pull-request-template', () => ({ resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration })) @@ -108,7 +107,6 @@ describe('RuntimeGitCommands', () => { mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body) mocks.loadPullRequestLinkedIssue.mockReset() mocks.loadPullRequestLinkedIssue.mockResolvedValue(null) - mocks.getSshGitProvider.mockReset() mocks.getStatus.mockReset() mocks.checkoutBranch.mockReset() mocks.discardChanges.mockReset() @@ -144,9 +142,7 @@ describe('RuntimeGitCommands', () => { }) }) - it('does not resolve shared link paths for a remote runtime status', async () => { - const provider = { getStatus: vi.fn().mockResolvedValue({ entries: [] }) } - mocks.getSshGitProvider.mockReturnValue(provider) + it('refuses an SSH runtime status without running git here', async () => { const commands = new RuntimeGitCommands({ resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree('/remote/repo'), @@ -156,9 +152,7 @@ describe('RuntimeGitCommands', () => { getRuntimeSettings: () => ({}) as GlobalSettings }) - await commands.getRuntimeGitStatus('id:wt-1') - - expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo') + await expect(commands.getRuntimeGitStatus('id:wt-1')).rejects.toThrow(SshHostNotServedError) expect(mocks.getStatus).not.toHaveBeenCalled() }) @@ -186,26 +180,6 @@ describe('RuntimeGitCommands', () => { }) }) - it('checks out a remote branch through the SSH git provider', async () => { - const provider = { checkoutBranch: vi.fn().mockResolvedValue(undefined) } - mocks.getSshGitProvider.mockReturnValue(provider) - const commands = new RuntimeGitCommands({ - resolveRuntimeGitTarget: async () => ({ - worktree: makeWorktree('/remote/repo'), - executionHostId: 'ssh:conn-1' - }), - getRuntimeSettings: () => ({}) as GlobalSettings - }) - - await expect(commands.checkoutRuntimeGitBranch('id:wt-1', 'feature/x')).resolves.toEqual({ - ok: true, - branch: 'feature/x' - }) - - expect(provider.checkoutBranch).toHaveBeenCalledWith('/remote/repo', 'feature/x') - expect(mocks.checkoutBranch).not.toHaveBeenCalled() - }) - it('lists local branches through the resolved worktree', async () => { const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) tempDirs.push(worktreePath) @@ -238,45 +212,6 @@ describe('RuntimeGitCommands', () => { }) }) - it('keeps a remote single-file discard owned by the SSH provider', async () => { - const provider = { discardChanges: vi.fn() } - mocks.getSshGitProvider.mockReturnValue(provider) - const commands = new RuntimeGitCommands({ - resolveRuntimeGitTarget: async () => ({ - worktree: makeWorktree('/remote/repo'), - executionHostId: 'ssh:conn-1' - }), - getRuntimeSettings: () => ({}) as GlobalSettings - }) - - await commands.discardRuntimeGitPath('id:wt-1', 'src/app.ts') - - expect(provider.discardChanges).toHaveBeenCalledWith('/remote/repo', 'src/app.ts') - expect(mocks.discardChanges).not.toHaveBeenCalled() - }) - - it('lists remote local branches through the SSH git provider', async () => { - const provider = { - listLocalBranches: vi.fn().mockResolvedValue({ current: 'main', branches: ['main'] }) - } - mocks.getSshGitProvider.mockReturnValue(provider) - const commands = new RuntimeGitCommands({ - resolveRuntimeGitTarget: async () => ({ - worktree: makeWorktree('/remote/repo'), - executionHostId: 'ssh:conn-1' - }), - getRuntimeSettings: () => ({}) as GlobalSettings - }) - - await expect(commands.listRuntimeGitLocalBranches('id:wt-1')).resolves.toEqual({ - current: 'main', - branches: ['main'] - }) - - expect(provider.listLocalBranches).toHaveBeenCalledWith('/remote/repo') - expect(mocks.listLocalBranches).not.toHaveBeenCalled() - }) - it('rejects slash-only git mutation paths before they can target the worktree root', async () => { const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) tempDirs.push(worktreePath) @@ -582,24 +517,10 @@ describe('RuntimeGitCommands', () => { ) }) - it('resolves remote commit-message settings against the SSH host cache', async () => { + it('resolves SSH commit-message settings against the host cache, then reports it unserved', async () => { const worktreePath = '/remote/repo' - const context = { - branch: 'main', - stagedSummary: 'M\tREADME.md', - stagedPatch: '+hello' - } const params = { agentId: 'cursor', model: 'remote-model' } mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params }) - mocks.generateCommitMessageFromContext.mockResolvedValue({ - success: true, - message: 'docs: update remote readme' - }) - const provider = { - getStagedCommitContext: vi.fn().mockResolvedValue(context), - executeCommitMessagePlan: vi.fn() - } - mocks.getSshGitProvider.mockReturnValue(provider) const commands = new RuntimeGitCommands({ resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath), @@ -616,8 +537,8 @@ describe('RuntimeGitCommands', () => { }) await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({ - success: true, - message: 'docs: update remote readme' + success: false, + error: SSH_HOST_NOT_SERVED_MESSAGE }) expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith( @@ -626,14 +547,8 @@ describe('RuntimeGitCommands', () => { 'commitMessage', null ) - expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( - context, - params, - expect.objectContaining({ - kind: 'remote', - cwd: worktreePath - }) - ) + expect(mocks.getStagedCommitContext).not.toHaveBeenCalled() + expect(mocks.generateCommitMessageFromContext).not.toHaveBeenCalled() }) it('enriches the local commit context with the workspace linked issue', async () => { @@ -658,33 +573,6 @@ describe('RuntimeGitCommands', () => { ) }) - it('enriches the SSH commit context with the workspace linked issue', async () => { - const worktreePath = '/home/tester/wt' - const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } - const params = { agentId: 'cursor', model: 'remote-model' } - mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params }) - mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' }) - mocks.getSshGitProvider.mockReturnValue({ - getStagedCommitContext: vi.fn().mockResolvedValue(context), - executeCommitMessagePlan: vi.fn() - }) - const commands = new RuntimeGitCommands({ - resolveRuntimeGitTarget: async () => ({ - worktree: makeWorktree(worktreePath, 77), - executionHostId: 'ssh:conn-1' - }), - getRuntimeSettings: () => ({}) as GlobalSettings - }) - - await commands.generateRuntimeCommitMessage('id:wt-1') - - expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( - { ...context, linkedIssue: 77 }, - params, - expect.objectContaining({ kind: 'remote' }) - ) - }) - it('prefers live meta over the linked issue projected onto the resolved worktree', async () => { const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) tempDirs.push(worktreePath) @@ -806,53 +694,6 @@ describe('RuntimeGitCommands', () => { ) }) - it('shares one linked-issue attach across both pull-request branches', async () => { - const context = { - base: 'main', - branch: 'feature/login', - commitSummary: 'abc123 feat: test', - changeSummary: 'M README.md', - patch: '+hello', - currentTitle: '', - currentBody: '', - currentDraft: false - } - const params = { agentId: 'codex' as const, model: 'gpt-5.5' } - mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} }) - mocks.getSshGitProvider.mockReturnValue({ - exec: vi.fn(), - executeCommitMessagePlan: vi.fn() - }) - - for (const connectionId of [undefined, 'conn-1']) { - const worktreePath = connectionId - ? '/home/tester/wt' - : mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) - if (!connectionId) { - tempDirs.push(worktreePath) - } - mocks.getPullRequestDraftContext.mockResolvedValue(context) - const commands = new RuntimeGitCommands({ - resolveRuntimeGitTarget: async () => ({ - worktree: makeWorktree(worktreePath, 55), - executionHostId: connectionId ? (`ssh:${connectionId}` as const) : ('local' as const) - }), - getRuntimeSettings: () => ({}) as GlobalSettings - }) - - await commands.generateRuntimePullRequestFields( - 'id:wt-1', - { base: 'main', title: '', body: '', draft: false }, - { sourceControlAiResolvedParams: params } - ) - } - - expect(mocks.generatePullRequestFieldsFromContext.mock.calls).toHaveLength(2) - for (const call of mocks.generatePullRequestFieldsFromContext.mock.calls) { - expect(call[0]).toEqual({ ...context, linkedIssue: 55 }) - } - }) - it('leaves the pull-request context untouched when no issue is linked', async () => { const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) tempDirs.push(worktreePath) diff --git a/src/main/runtime/orca-runtime-list-managed-worktrees.ts b/src/main/runtime/orca-runtime-list-managed-worktrees.ts index 24e651942b2..dc193608404 100644 --- a/src/main/runtime/orca-runtime-list-managed-worktrees.ts +++ b/src/main/runtime/orca-runtime-list-managed-worktrees.ts @@ -28,7 +28,6 @@ import { scanWorkspacePortsOnExecutionHost, type WorkspacePortExecutionHostDeps } from '../ports/workspace-port-execution-host' -import { getActiveMultiplexer } from '../ssh/ssh-target-registry' import { resolveWorktreeHostRouting } from './worktree-launch-host-repo' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -178,8 +177,7 @@ export class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreS protected getWorkspacePortHostDeps(): WorkspacePortExecutionHostDeps { return { - getLocalProbes: () => this.getWorkspacePortProbes(), - getSshMultiplexer: getActiveMultiplexer + getLocalProbes: () => this.getWorkspacePortProbes() } } diff --git a/src/main/runtime/orca-runtime-quick-open-capabilities.test.ts b/src/main/runtime/orca-runtime-quick-open-capabilities.test.ts index ce30c18b486..f109d771044 100644 --- a/src/main/runtime/orca-runtime-quick-open-capabilities.test.ts +++ b/src/main/runtime/orca-runtime-quick-open-capabilities.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { getSshFilesystemProviderMock } from './orca-runtime-files-mock-registry' +import { readdirMock, wslAwareSpawnMock } from './orca-runtime-files-mock-registry' import { createRuntimeFileCommands, useRuntimeFileCommandsLifecycle @@ -26,214 +26,28 @@ vi.mock( vi.mock('../ripgrep/bundled-ripgrep-path', async () => (await import('./orca-runtime-files-mock-registry')).bundledRipgrepPathModuleMock() ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) - -function remoteSearch(version: number | null) { - const latePath = 'late/target.ts' - const legacy = Array.from({ length: 32 }, (_, i) => `early/unrelated${i}.ts`) - const listFiles = vi.fn(async (_root: string, options?: { searchQuery?: string }) => - options?.searchQuery ? [latePath] : legacy - ) - const supportsQuickOpenSearch = vi.fn( - async (options?: { minimumVersion?: number; signal?: AbortSignal }) => { - options?.signal?.throwIfAborted() - return version !== null && version >= (options?.minimumVersion ?? 3) - } - ) - getSshFilesystemProviderMock.mockReturnValue({ - listFiles, - ...(version === null ? {} : { supportsQuickOpenSearch }) - }) - return { - ...createRuntimeFileCommands({ hostId: 'ssh:ssh-1' }), - listFiles, - supportsQuickOpenSearch - } -} - -describe('runtime SSH Quick Open capability requirements', () => { +// Why: this process serves no SSH filesystem, so Quick Open on an `ssh:` workspace reports no +// matches (version 0) instead of failing the keystroke, and never lists this machine. +describe('runtime SSH Quick Open', () => { useRuntimeFileCommandsLifecycle() - it.each([1, 2, 3, 4])( - 'uses host search for a simple late match on version %i', - async (version) => { - const { commands, listFiles, supportsQuickOpenSearch } = remoteSearch(version) - const controller = new AbortController() - await expect( - commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7, ['nested'], controller.signal) - ).resolves.toMatchObject({ files: [{ relativePath: 'late/target.ts' }], truncated: false }) - expect(supportsQuickOpenSearch).toHaveBeenCalledWith({ - signal: controller.signal, - minimumVersion: 1 - }) - expect(listFiles).toHaveBeenCalledWith('/repo', { - excludePaths: ['nested'], - maxResults: 8, - searchQuery: 'target', - signal: controller.signal - }) - } - ) + it('reports no matches and capability 0 without listing this machine', async () => { + const { commands } = createRuntimeFileCommands({ hostId: 'ssh:ssh-1' }) - it.each([0, null])( - 'keeps the bounded compatibility prefix only without v1 (%s)', - async (version) => { - const { commands, listFiles } = remoteSearch(version) - await expect( - commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7) - ).resolves.toMatchObject({ - files: [], - truncated: true - }) - expect(listFiles).toHaveBeenCalledWith('/repo', { - excludePaths: undefined, - maxResults: 32, - signal: undefined - }) - } - ) - - it.each(['package-lock', 'my_file', 'file name'])( - 'preserves existing filename search %s on older relays', - async (query) => { - for (const version of [1, 2, 3]) { - const { commands, listFiles } = remoteSearch(version) - await expect(commands.searchQuickOpenFilePaths('id:wt-1', query, 7)).resolves.toMatchObject( - { - files: [{ relativePath: 'late/target.ts' }] - } - ) - expect(listFiles).toHaveBeenCalledWith( - '/repo', - expect.objectContaining({ searchQuery: query, maxResults: 8 }) - ) - } - } - ) - - it('does not require v3 for whitespace only around a simple query', async () => { - const { commands, supportsQuickOpenSearch } = remoteSearch(1) - await commands.searchQuickOpenFilePaths('id:wt-1', ' target ', 7) - expect(supportsQuickOpenSearch).toHaveBeenCalledWith({ signal: undefined, minimumVersion: 1 }) - }) - - it.each([{ includeIgnored: false }, { followSymlinks: true }])( - 'requires v2 for discovery %j', - async (options) => { - for (const version of [null, 0, 1]) { - const { commands, listFiles } = remoteSearch(version) - await expect( - commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7, undefined, undefined, options) - ).rejects.toThrow('listing options') - expect(listFiles).not.toHaveBeenCalled() - } - const { commands, listFiles } = remoteSearch(2) - await commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7, undefined, undefined, options) - expect(listFiles).toHaveBeenCalledWith( - '/repo', - expect.objectContaining({ ...options, searchQuery: 'target', maxResults: 8 }) - ) - } - ) - - it('forwards abort to the capability probe and starts no listing after rejection', async () => { - const { commands, listFiles } = remoteSearch(1) - const controller = new AbortController() - controller.abort(new Error('abandoned')) + await expect(commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7)).resolves.toMatchObject({ + files: [], + totalCount: 0, + truncated: false, + quickOpenSearchVersion: 0 + }) await expect( - commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7, undefined, controller.signal) - ).rejects.toThrow('abandoned') - expect(listFiles).not.toHaveBeenCalled() - }) -}) - -describe('runtime SSH file-list capability requirements', () => { - useRuntimeFileCommandsLifecycle() - - it.each([{ includeIgnored: false }, { followSymlinks: true }])( - 'requires discovery v2 for %j', - async (options) => { - for (const version of [null, 0, 1]) { - const { commands, listFiles } = remoteSearch(version) - await expect(commands.listRuntimeFiles('id:wt-1', options)).rejects.toThrow( - 'listing options' - ) - expect(listFiles).not.toHaveBeenCalled() - } - for (const version of [2, 3, 4]) { - const { commands, listFiles, supportsQuickOpenSearch } = remoteSearch(version) - const controller = new AbortController() - await commands.listRuntimeFiles('id:wt-1', { - ...options, - signal: controller.signal, - maxResults: 9 - }) - expect(supportsQuickOpenSearch).toHaveBeenCalledWith({ - minimumVersion: 2, - signal: controller.signal - }) - expect(listFiles).toHaveBeenCalledWith( - '/repo', - expect.objectContaining({ ...options, signal: controller.signal, maxResults: 9 }) - ) - } - } - ) - - it('requires v3 for candidate validation even with default discovery settings', async () => { - for (const version of [null, 0, 1, 2]) { - const { commands, listFiles } = remoteSearch(version) - await expect( - commands.listRuntimeFiles('id:wt-1', { candidatePaths: ['recent.ts'] }) - ).rejects.toThrow('validate Quick Open recent files') - expect(listFiles).not.toHaveBeenCalled() - } - const { commands, supportsQuickOpenSearch, listFiles } = remoteSearch(3) - await commands.listRuntimeFiles('id:wt-1', { - candidatePaths: ['recent.ts'], - includeIgnored: false + commands.listRuntimeFiles('id:wt-1', { candidatePaths: ['recent.ts'], followSymlinks: true }) + ).resolves.toEqual([]) + await expect(commands.listMobileFiles('id:wt-1')).resolves.toMatchObject({ + files: [], + totalCount: 0 }) - expect(supportsQuickOpenSearch).toHaveBeenCalledWith({ minimumVersion: 3, signal: undefined }) - expect(listFiles).toHaveBeenCalledWith( - '/repo', - expect.objectContaining({ candidatePaths: ['recent.ts'], includeIgnored: false }) - ) - }) - - it('keeps default legacy listings and disconnected results unchanged', async () => { - const { commands, supportsQuickOpenSearch, listFiles } = remoteSearch(0) - await commands.listRuntimeFiles('id:wt-1', { includeIgnored: true, followSymlinks: false }) - expect(listFiles).toHaveBeenCalledOnce() - expect(supportsQuickOpenSearch).not.toHaveBeenCalled() - getSshFilesystemProviderMock.mockReturnValue(null) - await expect(commands.listRuntimeFiles('id:wt-1', { followSymlinks: true })).resolves.toEqual( - [] - ) + expect(readdirMock).not.toHaveBeenCalled() + expect(wslAwareSpawnMock).not.toHaveBeenCalled() }) }) - -it.each([0, 1, 2, 3, 4])( - 'reports execution relay matching capability %i through a paired runtime', - async (version) => { - const { commands } = remoteSearch(version) - const result = await commands.searchQuickOpenFilePaths('id:wt-1', '', 7) - expect(result.quickOpenSearchVersion).toBe(Math.min(version, 3)) - } -) - -it('keeps inherited ignored-file visibility from breaking searches on an older relay', async () => { - const { commands, listFiles } = remoteSearch(1) - await expect( - commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7, undefined, undefined, { - includeIgnored: false, - allowLegacyIncludeIgnored: true - }) - ).resolves.toMatchObject({ files: [{ relativePath: 'late/target.ts' }] }) - expect(listFiles).toHaveBeenCalledWith( - '/repo', - expect.not.objectContaining({ includeIgnored: false }) - ) -}) diff --git a/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-02.spec.ts index 3bc909dccaa..9767ea2aa4d 100644 --- a/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-02.spec.ts @@ -1,3 +1,4 @@ +import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { describe, expect, it, vi } from 'vitest' import { ORIGIN_HEAD_COMPONENT, @@ -9,7 +10,6 @@ import { getGlabKnownHostsMock, getPullRequestPushTargetMock, gitRunner, - registerSshGitProvider, setPlatform } from '../orca-runtime-test-mocks.spec' import { @@ -178,7 +178,7 @@ describe('OrcaRuntimeService', () => { } }) - it('resolves SSH GitHub fork PR heads through the write-capable fetch RPC', async () => { + it('refuses SSH GitHub fork PR heads instead of resolving them on this machine', async () => { const remoteRepo = { id: TEST_REPO_ID, path: '/remote/repo', @@ -193,54 +193,17 @@ describe('OrcaRuntimeService', () => { getRepos: () => [remoteRepo], getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) } - const provider = { - exec: vi.fn(async (args: string[]) => { - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: `${ORIGIN_REMOTE_URL}\n`, stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\nupstream\n', stderr: '' } - } - if ( - args[0] === 'rev-parse' && - args[2] === `refs/orca/pull/${ORIGIN_HEAD_COMPONENT}/42^{commit}` - ) { - return { stdout: 'remote-fork-pr-sha\n', stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }), - fetchGitHubPullRequestHead: vi - .fn() - .mockResolvedValue(`refs/orca/pull/${ORIGIN_HEAD_COMPONENT}/42`), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined) - } - registerSshGitProvider('ssh-1', provider as never) const runtime = new OrcaRuntimeService(runtimeStore as never) - const result = await runtime.resolveManagedPrBase({ - repoSelector: 'id:repo-1', - prNumber: 42, - headRefName: 'contributor/fix', - isCrossRepository: true - }) - - expect(result).toEqual({ - baseBranch: 'remote-fork-pr-sha', - headSha: 'remote-fork-pr-sha', - branchNameOverride: 'contributor/fix' - }) - expect(provider.fetchGitHubPullRequestHead).toHaveBeenCalledWith('/remote/repo', 'origin', 42) - expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( - '/remote/repo', - 42, - 'ssh-1', - {}, - 'origin' - ) - expect(provider.exec).not.toHaveBeenCalledWith( - expect.arrayContaining(['fetch']), - '/remote/repo' - ) + await expect( + runtime.resolveManagedPrBase({ + repoSelector: 'id:repo-1', + prNumber: 42, + headRefName: 'contributor/fix', + isCrossRepository: true + }) + ).rejects.toThrow(SshHostNotServedError) + expect(getPullRequestPushTargetMock).not.toHaveBeenCalled() }) it('resolves local GitLab fork MR bases from the target project MR head ref', async () => { diff --git a/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-03.spec.ts index 056d951e8df..0ae975cb067 100644 --- a/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases-part-03.spec.ts @@ -1,17 +1,15 @@ +import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { describe, expect, it, vi } from 'vitest' import { - ORIGIN_HEAD_COMPONENT, - ORIGIN_REMOTE_URL, OrcaRuntimeService, getGitLabProjectRefForRemoteMock, getGlabKnownHostsMock, - gitRunner, - registerSshGitProvider + gitRunner } from '../orca-runtime-test-mocks.spec' import { TEST_REPO_ID, TEST_REPO_PATH, store } from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { - it('resolves SSH GitLab fork MR bases from the target project MR head ref', async () => { + it('refuses SSH GitLab MR bases instead of resolving them on this machine', async () => { const remoteRepo = { id: TEST_REPO_ID, path: '/remote/repo', @@ -26,122 +24,21 @@ describe('OrcaRuntimeService', () => { getRepos: () => [remoteRepo], getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) } - const provider = { - exec: vi.fn(async (args: string[]) => { - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: `${ORIGIN_REMOTE_URL}\n`, stderr: '' } - } - if ( - args[0] === 'rev-parse' && - args[1] === '--verify' && - args[2] === `refs/orca/merge-requests/${ORIGIN_HEAD_COMPONENT}/77^{commit}` - ) { - return { stdout: 'remote-fork-mr-sha\n', stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }), - fetchGitLabMergeRequestHead: vi - .fn() - .mockResolvedValue(`refs/orca/merge-requests/${ORIGIN_HEAD_COMPONENT}/77`), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined) - } - registerSshGitProvider('ssh-1', provider as never) - getGlabKnownHostsMock.mockResolvedValue(['gitlab.com', 'git.internal']) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync') const runtime = new OrcaRuntimeService(runtimeStore as never) - const result = await runtime.resolveManagedMrBase({ - repoSelector: 'id:repo-1', - mrIid: 77, - sourceBranch: 'contrib/remote-fix', - targetBranch: 'main', - isCrossRepository: true - }) - - expect(result).toEqual({ - baseBranch: 'remote-fork-mr-sha', - compareBaseRef: 'refs/remotes/origin/main' - }) - expect(provider.fetchGitLabMergeRequestHead).toHaveBeenCalledWith('/remote/repo', 'origin', 77) - expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( - '/remote/repo', - 'origin', - 'main', - 'refs/remotes/origin/main' - ) - expect(provider.exec).toHaveBeenCalledWith( - ['rev-parse', '--verify', `refs/orca/merge-requests/${ORIGIN_HEAD_COMPONENT}/77^{commit}`], - '/remote/repo' - ) - expect(getGitLabProjectRefForRemoteMock).toHaveBeenCalledWith( - '/remote/repo', - 'origin', - ['gitlab.com', 'git.internal'], - 'ssh-1' - ) - }) - - it('resolves SSH GitLab same-repo MR bases through remote-tracking fetches', async () => { - const remoteRepo = { - id: TEST_REPO_ID, - path: '/remote/repo', - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1', - issueSourcePreference: 'origin' as const - } - const runtimeStore = { - ...store, - getRepos: () => [remoteRepo], - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) - } - const provider = { - exec: vi.fn(async (args: string[]) => { - if (args[0] === 'rev-parse' && args[1] === '--verify' && args[2] === 'origin/feature/fix') { - return { stdout: 'same-repo-mr-sha\n', stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }), - fetchGitLabMergeRequestHead: vi.fn().mockResolvedValue(undefined), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined) - } - registerSshGitProvider('ssh-1', provider as never) - getGlabKnownHostsMock.mockResolvedValue(['gitlab.com', 'git.internal']) - getGitLabProjectRefForRemoteMock.mockResolvedValue({ - host: 'gitlab.example', - path: 'group/repo' - }) - const runtime = new OrcaRuntimeService(runtimeStore as never) - - const result = await runtime.resolveManagedMrBase({ - repoSelector: 'id:repo-1', - mrIid: 78, - sourceBranch: 'feature/fix', - targetBranch: 'main' - }) - - expect(result).toEqual({ - baseBranch: 'origin/feature/fix', - compareBaseRef: 'refs/remotes/origin/main', - pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } - }) - expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( - '/remote/repo', - 'origin', - 'feature/fix', - 'refs/remotes/origin/feature/fix' - ) - expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( - '/remote/repo', - 'origin', - 'main', - 'refs/remotes/origin/main' - ) - expect(provider.fetchGitLabMergeRequestHead).not.toHaveBeenCalled() - expect(provider.exec).toHaveBeenCalledWith( - ['rev-parse', '--verify', 'origin/feature/fix'], - '/remote/repo' - ) + await expect( + runtime.resolveManagedMrBase({ + repoSelector: 'id:repo-1', + mrIid: 77, + sourceBranch: 'feature', + targetBranch: 'main', + isCrossRepository: true + }) + ).rejects.toThrow(SshHostNotServedError) + expect(getGlabKnownHostsMock).not.toHaveBeenCalled() + expect(gitSpy).not.toHaveBeenCalled() + gitSpy.mockRestore() }) it('keeps the MR source base when the optional compare-base fetch fails', async () => { @@ -260,68 +157,4 @@ describe('OrcaRuntimeService', () => { gitSpy.mockRestore() } }) - - it('keeps a cross-repo fork MR compare base when the fetch fails but the local ref resolves', async () => { - // Why: mirror the GitHub fork soft-fail-keep — a transient compare-base fetch - // failure must not drop a base we already have on disk onto the fork MR head SHA. - const remoteRepo = { - id: TEST_REPO_ID, - path: '/remote/repo', - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1', - issueSourcePreference: 'origin' as const - } - const runtimeStore = { - ...store, - getRepos: () => [remoteRepo], - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) - } - const durableLocalRef = `refs/orca/merge-requests/${ORIGIN_HEAD_COMPONENT}/77` - const provider = { - exec: vi.fn(async (args: string[]) => { - if (args[0] === 'rev-parse' && args[2] === `${durableLocalRef}^{commit}`) { - return { stdout: 'remote-fork-mr-sha\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') { - return { stdout: 'base-commit-sha\n', stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }), - fetchGitLabMergeRequestHead: vi.fn().mockResolvedValue(durableLocalRef), - fetchRemoteTrackingRef: vi.fn(async () => { - throw new Error('fatal: unable to access repo: Could not resolve host: gitlab.example') - }) - } - registerSshGitProvider('ssh-1', provider as never) - getGlabKnownHostsMock.mockResolvedValue(['gitlab.com', 'git.internal']) - getGitLabProjectRefForRemoteMock.mockResolvedValue({ - host: 'gitlab.example', - path: 'group/repo' - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const runtime = new OrcaRuntimeService(runtimeStore as never) - - try { - const result = await runtime.resolveManagedMrBase({ - repoSelector: 'id:repo-1', - mrIid: 77, - sourceBranch: 'contrib/remote-fix', - targetBranch: 'main', - isCrossRepository: true - }) - - expect(result).toEqual({ - baseBranch: 'remote-fork-mr-sha', - compareBaseRef: 'refs/remotes/origin/main' - }) - expect(provider.exec).toHaveBeenCalledWith( - ['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], - '/remote/repo' - ) - } finally { - warnSpy.mockRestore() - } - }) }) diff --git a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts index 139e018025a..34cda355e43 100644 --- a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts +++ b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { OrcaRuntimeService, addGitHubIssueCommentMock, @@ -15,12 +15,9 @@ import { listGitHubIssuesMock, listGitHubLabelsMock, listGitHubWorkItemsMock, - parseOrcaYaml, - registerSshFilesystemProvider, removeGitHubPRReviewersMock, requestGitHubPRReviewersMock, setPlatform, - unregisterSshFilesystemProvider, updateGitHubIssueMock } from '../orca-runtime-test-mocks.spec' import { TEST_REPO_ID, TEST_REPO_PATH, store } from '../orca-runtime-test-fixtures.spec' @@ -52,26 +49,13 @@ describe('OrcaRuntimeService', () => { }) }) - it('reports ok for a missing remote orca.yaml and error for any other read failure', async () => { - const readFile = vi.fn() - registerSshFilesystemProvider('ssh-1', { readFile } as never) + it('reports an SSH orca.yaml as an error without reading any filesystem', async () => { const runtime = new OrcaRuntimeService(remoteStore as never) - try { - readFile.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' })) - await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({ - status: 'ok', - hasHooks: false - }) - - readFile.mockRejectedValueOnce(Object.assign(new Error('down'), { code: 'ECONNRESET' })) - await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({ - status: 'error', - hasHooks: false - }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } + await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({ + status: 'error', + hasHooks: false + }) }) it('reports ok for a local repo hook check', async () => { @@ -81,7 +65,7 @@ describe('OrcaRuntimeService', () => { }) }) - it('resolves SSH issue commands from shared orca.yaml and deletes empty overrides', async () => { + it('reads SSH issue commands as absent and drops writes without touching any filesystem', async () => { const remoteStore = { ...store, getRepos: () => [ @@ -95,48 +79,18 @@ describe('OrcaRuntimeService', () => { } ] } - vi.mocked(parseOrcaYaml).mockReturnValue({ - scripts: {}, - issueCommand: 'claude -p "Fix #{{issue}}"' - }) - const fsProvider = { - readFile: vi.fn(async (filePath: string) => { - if (filePath.endsWith('.orca/issue-command')) { - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - } - if (filePath.endsWith('orca.yaml')) { - return { content: 'issueCommand: claude -p "Fix #{{issue}}"', isBinary: false } - } - return { content: '', isBinary: false } - }), - writeFile: vi.fn().mockResolvedValue(undefined), - createDir: vi.fn().mockResolvedValue(undefined), - deletePath: vi.fn().mockResolvedValue(undefined) - } - registerSshFilesystemProvider('ssh-1', fsProvider as never) const runtime = new OrcaRuntimeService(remoteStore as never) - try { - await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({ - localContent: null, - sharedContent: 'claude -p "Fix #{{issue}}"', - effectiveContent: 'claude -p "Fix #{{issue}}"', - localFilePath: '/remote/repo/.orca/issue-command', - source: 'shared' - }) - await expect(runtime.writeRepoIssueCommand('id:repo-1', ' ')).resolves.toEqual({ - ok: true - }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') - expect(fsProvider.deletePath).toHaveBeenCalledWith('/remote/repo/.orca/issue-command', false) - expect(fsProvider.writeFile).not.toHaveBeenCalledWith( - '/remote/repo/.orca/issue-command', - expect.anything() - ) + await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({ + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '/remote/repo/.orca/issue-command', + source: 'none' + }) + await expect(runtime.writeRepoIssueCommand('id:repo-1', ' ')).resolves.toEqual({ + ok: true + }) }) it('allows host integration slug helpers for SSH repos through provider-aware GitHub clients', async () => { diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts index 5ddd5fe3290..0eaf37a80d9 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts @@ -8,7 +8,6 @@ import { mkdir, mkdtemp, registerSshFilesystemProvider, - registerSshGitProvider, rm, tmpdir, unregisterSshFilesystemProvider @@ -38,7 +37,7 @@ describe('OrcaRuntimeService', () => { ) }) - it('returns capped SSH refs for empty runtime repo searches', async () => { + it('reports no refs for an SSH repo and still reports the execution cap', async () => { const remoteRepo = { id: 'remote-repo', path: '/home/user/repo', @@ -52,156 +51,16 @@ describe('OrcaRuntimeService', () => { getRepos: () => [remoteRepo], getRepo: () => remoteRepo } - const provider = { - exec: vi.fn().mockImplementation((argv: string[]) => { - if (argv[0] === 'remote') { - return Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' }) - } - return Promise.resolve({ - stdout: [ - 'refs/remotes/origin/main\0origin/main', - 'refs/remotes/upstream/feature-x\0upstream/feature-x', - 'refs/remotes/upstream/HEAD\0upstream/HEAD', - 'refs/heads/local-only\0local-only' - ].join('\n'), - stderr: '' - }) - }) - } - registerSshGitProvider('ssh-1', provider as never) const runtime = new OrcaRuntimeService(runtimeStore as never) - const result = await runtime.searchRepoRefs('id:remote-repo', '', 2) - - expect(result).toEqual({ - refs: ['origin/main', 'upstream/feature-x'], - refDetails: [ - { refName: 'origin/main', localBranchName: 'main' }, - { refName: 'upstream/feature-x', localBranchName: 'feature-x' } - ], - truncated: true + await expect(runtime.searchRepoRefs('id:remote-repo', '', 5)).resolves.toEqual({ + refs: [], + refDetails: [], + truncated: false }) - expect(provider.exec).toHaveBeenCalledWith( - expect.arrayContaining([ - '--exclude=refs/remotes/*/HEAD', - '--count=12', - 'refs/heads/**/**', - 'refs/heads/**/**/**', - 'refs/remotes/**/**', - 'refs/remotes/**/**/**' - ]), - '/home/user/repo' - ) - expect(provider.exec).toHaveBeenCalledWith(['remote'], '/home/user/repo') - }) - - it('clamps oversized SSH ref-search limits and reports the execution cap', async () => { - const remoteRepo = { - id: 'remote-repo-large-limit', - path: '/home/user/repo', - displayName: 'remote', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-large-limit' - } - const runtimeStore = { - ...store, - getRepos: () => [remoteRepo], - getRepo: () => remoteRepo - } - const provider = { - exec: vi.fn().mockImplementation((argv: string[]) => { - if (argv[0] === 'remote') { - return Promise.resolve({ stdout: 'origin\n', stderr: '' }) - } - return Promise.resolve({ - stdout: 'refs/remotes/origin/main\0origin/main', - stderr: '' - }) - }) - } - registerSshGitProvider('ssh-large-limit', provider as never) - const runtime = new OrcaRuntimeService(runtimeStore as never) - - const result = await runtime.searchRepoRefs( - 'id:remote-repo-large-limit', - '', - REPO_SEARCH_REFS_MAX_LIMIT + 1 - ) - - expect(result).toEqual({ - refs: ['origin/main'], - refDetails: [{ refName: 'origin/main', localBranchName: 'main' }], - truncated: true - }) - const forEachRefCall = provider.exec.mock.calls.find( - (call) => (call[0] as string[])[0] === 'for-each-ref' - ) - expect(forEachRefCall?.[0]).toContain('--count=4004') - }) - - it('retries runtime SSH ref searches without --exclude for older git hosts', async () => { - const remoteRepo = { - id: 'remote-repo', - path: '/home/user/repo', - displayName: 'remote', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1' - } - const runtimeStore = { - ...store, - getRepos: () => [remoteRepo], - getRepo: () => remoteRepo - } - const provider = { - exec: vi.fn().mockImplementation((argv: string[]) => { - if (argv[0] === 'remote') { - return Promise.resolve({ stdout: 'origin\n', stderr: '' }) - } - if (argv.some((arg) => arg.startsWith('--exclude=refs/remotes/'))) { - return Promise.reject( - Object.assign(new Error("unknown option `exclude'"), { - stderr: "error: unknown option `exclude'" - }) - ) - } - return Promise.resolve({ - stdout: [ - 'refs/remotes/origin/main\0origin/main', - 'refs/remotes/origin/HEAD\0origin/HEAD', - 'refs/remotes/origin/feature-x\0origin/feature-x' - ].join('\n'), - stderr: '' - }) - }) - } - registerSshGitProvider('ssh-1', provider as never) - const runtime = new OrcaRuntimeService(runtimeStore as never) - - const result = await runtime.searchRepoRefs('id:remote-repo', '', 1) - const repeatedResult = await runtime.searchRepoRefs('id:remote-repo', '', 1) - - expect(result).toEqual({ - refs: ['origin/main'], - refDetails: [{ refName: 'origin/main', localBranchName: 'main' }], - truncated: true - }) - expect(repeatedResult).toEqual(result) - const forEachRefCalls = provider.exec.mock.calls.filter( - (call) => (call[0] as string[])[0] === 'for-each-ref' - ) - expect(forEachRefCalls).toHaveLength(3) - expect( - (forEachRefCalls[0][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/')) - ).toBe(true) - expect( - (forEachRefCalls[1][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/')) - ).toBe(false) - expect(forEachRefCalls[1][0]).toContain('--count=108') - expect( - (forEachRefCalls[2][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/')) - ).toBe(false) + await expect( + runtime.searchRepoRefs('id:remote-repo', '', REPO_SEARCH_REFS_MAX_LIMIT + 1) + ).resolves.toEqual({ refs: [], refDetails: [], truncated: true }) }) it('resolves SSH worktrees when manually updating lineage', async () => { diff --git a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts index 5394bbacb85..5f88339d47c 100644 --- a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts @@ -14,7 +14,6 @@ import { listWorktrees, loadHooks, muxRequestMock, - parseOrcaYaml, registerSshFilesystemProvider, registerSshGitProvider, removeWorktree, @@ -537,7 +536,7 @@ describe('OrcaRuntimeService', () => { expect(removeWorktree).not.toHaveBeenCalled() }) - it('reads SSH repo hooks through the SSH filesystem provider', async () => { + it('reports SSH repo hooks as absent without reading this machine', async () => { const remoteStore = { ...store, getRepos: () => [ @@ -551,31 +550,13 @@ describe('OrcaRuntimeService', () => { } ] } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'scripts:\n setup: pnpm install\n', - isBinary: false - }) - } - vi.mocked(parseOrcaYaml).mockReturnValue({ scripts: { setup: 'pnpm install' } }) - registerSshFilesystemProvider('ssh-1', fsProvider as never) const runtime = new OrcaRuntimeService(remoteStore as never) - try { - await expect(runtime.getRepoHooks('id:repo-1')).resolves.toMatchObject({ - hasHooksFile: true, - hooks: { scripts: { setup: 'pnpm install' } }, - source: 'orca.yaml', - setupTrust: { - contentHash: '005d0b7e5c261dcc5e2f8568e69a0b30e889a3275b55b18ec20a7deef0081e90', - scriptContent: 'pnpm install' - } - }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\orca.yaml') + await expect(runtime.getRepoHooks('id:repo-1')).resolves.toMatchObject({ + hasHooksFile: false, + hooks: null, + source: null + }) expect(hasHooksFile).not.toHaveBeenCalled() expect(getEffectiveHooks).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-03.spec.ts index b4eec96c294..efea6fafb63 100644 --- a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-03.spec.ts @@ -1,13 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' -import { - OrcaRuntimeService, - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../orca-runtime-test-mocks.spec' +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' import { TEST_REPO_ID, store } from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { - it('uses remote path joins for SSH hook checks and issue-command files', async () => { + it('uses remote path joins for SSH issue-command files and touches no filesystem', async () => { const remoteStore = { ...store, getRepos: () => [ @@ -21,49 +17,19 @@ describe('OrcaRuntimeService', () => { } ] } - const fsProvider = { - readFile: vi.fn(async (filePath: string) => ({ - content: filePath.endsWith('orca.yaml') - ? 'scripts:\n setup: pnpm install\n' - : filePath.endsWith('.gitignore') - ? 'node_modules\n' - : 'Fix it', - isBinary: false - })), - writeFile: vi.fn().mockResolvedValue(undefined), - createDir: vi.fn().mockResolvedValue(undefined), - deletePath: vi.fn().mockResolvedValue(undefined) - } - registerSshFilesystemProvider('ssh-1', fsProvider as never) const runtime = new OrcaRuntimeService(remoteStore as never) - try { - await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({ - hasHooks: true, - mayNeedUpdate: false - }) - await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({ - localContent: 'Fix it', - effectiveContent: 'Fix it', - localFilePath: 'C:\\remote\\repo\\.orca\\issue-command' - }) - await expect(runtime.writeRepoIssueCommand('id:repo-1', 'Ship it')).resolves.toEqual({ - ok: true - }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\orca.yaml') - expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca\\issue-command') - expect(fsProvider.createDir).toHaveBeenCalledWith('C:\\remote\\repo\\.orca') - expect(fsProvider.writeFile).toHaveBeenCalledWith( - 'C:\\remote\\repo\\.orca\\issue-command', - 'Ship it\n' - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - 'C:\\remote\\repo\\.gitignore', - 'node_modules\n.orca\n' - ) + await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({ + status: 'error', + hasHooks: false + }) + await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({ + localContent: null, + effectiveContent: null, + localFilePath: 'C:\\remote\\repo\\.orca\\issue-command' + }) + await expect(runtime.writeRepoIssueCommand('id:repo-1', 'Ship it')).resolves.toEqual({ + ok: true + }) }) }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts index 58c1998e2da..7c9887a7f84 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts @@ -22,7 +22,6 @@ import { localWorktreeFilesystem, lstat, mkdir, - registerSshGitProvider, removeWorktree, removeWorktreeLinkedPathsMock, restoreLocalWatcherAfterFailedRemovalMock, @@ -30,10 +29,8 @@ import { rm, runHook, setPlatform, - unregisterSshGitProvider, writeFile } from '../orca-runtime-test-mocks.spec' -import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' import { TEST_REPO_ID, TEST_REPO_PATH, @@ -609,75 +606,4 @@ describe('OrcaRuntimeService', () => { 'def456' ) }) - - it('force-deletes an SSH branch that was preserved by runtime worktree removal', async () => { - const remoteRepo = { - ...store.getRepo(TEST_REPO_ID)!, - path: '/remote/repo', - connectionId: 'ssh-1' - } - const remoteWorktree = { - path: '/remote/feature-wt', - head: 'def456', - branch: 'feature/test', - isBare: false, - isMainWorktree: false - } - const remoteWorktreeId = `${remoteRepo.id}::${remoteWorktree.path}` - const metaById: Record = { - [remoteWorktreeId]: makeWorktreeMeta() - } - const runtimeStore = { - ...store, - getRepos: () => [remoteRepo], - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } - return metaById[worktreeId] - }, - removeWorktreeMeta: (worktreeId: string) => { - delete metaById[worktreeId] - } - } - const provider = { - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), - forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: remoteRepo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - remoteWorktree - ]), - removeWorktree: vi.fn().mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'def456' } - }) - } - registerSshGitProvider('ssh-1', provider as never) - const runtime = createWorktreeRemovalRuntime(runtimeStore) - - try { - await runtime.removeManagedWorktree(remoteWorktreeId) - const result = await runtime.forceDeletePreservedBranch( - remoteWorktreeId, - 'feature/test', - 'def456' - ) - - expect(result).toEqual({ deleted: true }) - expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith( - '/remote/repo', - 'feature/test', - 'def456' - ) - expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() - } finally { - unregisterSshGitProvider('ssh-1') - } - }) }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index 1017a8e9908..a9729c15b2b 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { resetWorktreeTestSshHostHome } from '../../worktree-removal-test-ssh-host-home' import { _settlePendingWorktreeRemovalsForTests } from '../../worktree-background-removal' +import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { OrcaRuntimeService, @@ -109,19 +110,11 @@ describe('OrcaRuntimeService', () => { }) expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, false) expect(metaById[TEST_WORKTREE_ID]?.hostId).toBe('local') - const result = await runtime.forceDeletePreservedBranch( - TEST_WORKTREE_ID, - 'feature/test', - 'def456', - 'ssh:ssh-1' - ) - - expect(result).toEqual({ deleted: true }) - expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith( - remoteRepo.path, - 'feature/test', - 'def456' - ) + // The SSH host is not served here, so the qualified host refuses instead of deleting locally. + await expect( + runtime.forceDeletePreservedBranch(TEST_WORKTREE_ID, 'feature/test', 'def456', 'ssh:ssh-1') + ).rejects.toThrow(SshHostNotServedError) + expect(provider.forceDeletePreservedBranch).not.toHaveBeenCalled() expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() } finally { unregisterSshGitProvider('ssh-1') diff --git a/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts index 5fedd5f83f1..edef0e391c8 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts @@ -1,3 +1,4 @@ +import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID, @@ -86,7 +87,7 @@ describe('OrcaRuntimeService', () => { }) }) - it('routes SSH-backed forward-slash UNC file and git paths without collapsing the root', async () => { + it('resolves SSH-backed forward-slash UNC paths without collapsing the root, then refuses the work', async () => { vi.mocked(listWorktrees).mockClear() vi.mocked(listWorktrees).mockRejectedValue(new Error('local git should not run for SSH repos')) const remoteStore = { @@ -134,8 +135,12 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(remoteStore as never) try { - await runtime.readFileExplorerDir('path://server/share/repo', 'src') - await runtime.getRuntimeGitStatus('path://server/share/repo') + await expect(runtime.readFileExplorerDir('path://server/share/repo', 'src')).rejects.toThrow( + SshHostNotServedError + ) + await expect(runtime.getRuntimeGitStatus('path://server/share/repo')).rejects.toThrow( + SshHostNotServedError + ) await expect(runtime.showRepo('path://server/share/repo')).resolves.toMatchObject({ path: '//Server/Share/Repo' }) @@ -146,8 +151,8 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).not.toHaveBeenCalled() expect(gitProvider.listWorktrees).toHaveBeenCalledWith('//Server/Share/Repo') - expect(fsProvider.readDir).toHaveBeenCalledWith('\\\\Server\\Share\\Repo\\src', {}) - expect(gitProvider.getStatus).toHaveBeenCalledWith('//Server/Share/Repo') + expect(fsProvider.readDir).not.toHaveBeenCalled() + expect(gitProvider.getStatus).not.toHaveBeenCalled() }) it.each([ @@ -174,7 +179,7 @@ describe('OrcaRuntimeService', () => { }) }) - it('routes SSH folder workspace file explorer paths through the filesystem provider', async () => { + it('refuses SSH folder workspace file explorer reads instead of reading this machine', async () => { const folderPath = '/srv/platform' const fsProvider = { stat: vi.fn(async (pathValue: string) => ({ @@ -201,24 +206,16 @@ describe('OrcaRuntimeService', () => { try { await expect( runtime.readFileExplorerDir(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src') - ).resolves.toHaveLength(1) + ).rejects.toThrow(SshHostNotServedError) await expect( runtime.readFileExplorerPreview(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src/app.ts') - ).resolves.toMatchObject({ - content: 'remote\n', - isBinary: false - }) + ).rejects.toThrow(SshHostNotServedError) } finally { unregisterSshFilesystemProvider('ssh-folder') } - expect(fsProvider.stat).toHaveBeenCalledWith(folderPath) - expect(fsProvider.readDir).toHaveBeenCalledWith('/srv/platform/src', {}) - expect(fsProvider.stat).toHaveBeenCalledWith('/srv/platform/src/app.ts') - expect(fsProvider.readFile).toHaveBeenCalledWith('/srv/platform/src/app.ts', { - maxBinaryBytes: 10 * 1024 * 1024, - maxTextBytes: 512 * 1024 - }) + expect(fsProvider.readDir).not.toHaveBeenCalled() + expect(fsProvider.readFile).not.toHaveBeenCalled() }) it('lists persisted SSH worktrees while the git provider is unavailable', async () => { diff --git a/src/main/runtime/orca-runtime-workspace-ports-host.test.ts b/src/main/runtime/orca-runtime-workspace-ports-host.test.ts index ff084e306ef..67ff11c0b92 100644 --- a/src/main/runtime/orca-runtime-workspace-ports-host.test.ts +++ b/src/main/runtime/orca-runtime-workspace-ports-host.test.ts @@ -2,13 +2,8 @@ import './rpc/unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeWithListManagedWorktrees } from './orca-runtime-list-managed-worktrees' -const muxes = vi.hoisted(() => new Map }>()) const scanWorkspacePortsMock = vi.hoisted(() => vi.fn()) -vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - getActiveMultiplexer: (targetId: string) => muxes.get(targetId) -})) vi.mock('../ports/local-workspace-port-scanner', () => ({ scanWorkspacePorts: scanWorkspacePortsMock })) @@ -31,29 +26,19 @@ function runtimeResolvingTo(executionHostId: string) { describe('host-scoped workspace port scans on the server', () => { afterEach(() => { - muxes.clear() scanWorkspacePortsMock.mockReset() vi.restoreAllMocks() }) - it("scans a folder workspace's SSH host, not this server", async () => { - const request = vi.fn(async () => ({ platform: 'linux', ports: [{ port: 3020, host: '::' }] })) - muxes.set('box', { request }) + it("reports a folder workspace's SSH host unavailable instead of scanning this server", async () => { const runtime = runtimeResolvingTo('ssh:box') const result = await runtime.scan('folder:fw-1') expect(runtime.host.resolveRuntimeFileTarget).toHaveBeenCalledWith('folder:fw-1') - expect(request).toHaveBeenCalledWith('ports.detect', undefined, expect.any(Object)) - expect(scanWorkspacePortsMock).not.toHaveBeenCalled() - expect(result).toMatchObject({ executionHostId: 'ssh:box', ports: [{ port: 3020 }] }) - }) - - it('answers unavailable for an SSH workspace whose host is not connected', async () => { - const result = await runtimeResolvingTo('ssh:box').scan('id:repo::/srv/app') - - expect(result.unavailableReason).toMatch(/not connected/) expect(scanWorkspacePortsMock).not.toHaveBeenCalled() + expect(result).toMatchObject({ executionHostId: 'ssh:box', ports: [] }) + expect(result.unavailableReason).toMatch(/Reconnect/) }) it('refuses Stop when the workspace now resolves to a different host than the row', async () => { diff --git a/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts b/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts index 13f740259c2..28c38c7166d 100644 --- a/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts +++ b/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts @@ -8,19 +8,11 @@ import { } from '../../../../shared/remote-rpc-content-budget' import { assertGitDiffWithinTransportBudget } from '../../../../shared/git-diff-transport-budget' import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { OrcaRuntimeService } from '../../orca-runtime' -import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from '../../orca-runtime-git' import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { GIT_METHODS } from './git' -const sshMocks = vi.hoisted(() => ({ getSshGitProvider: vi.fn() })) - -vi.mock('../../../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: sshMocks.getSshGitProvider -})) - const OVERSIZED_BASE64 = 'A'.repeat(REMOTE_RPC_MAX_CONTENT_BYTES + 1024) const OVERSIZED_DIFF: GitDiffResult = { @@ -152,26 +144,4 @@ describe('remote git diff transport budget', () => { }) } ) - - // Why: an SSH host forwards its provider's payload verbatim, so the cap cannot rely on the far - // side clamping — this walks the real RuntimeGitCommands with an unclamped forwarded diff. - it('caps an SSH-forwarded diff a remote client requested', async () => { - sshMocks.getSshGitProvider.mockReturnValue({ - getDiff: vi.fn().mockResolvedValue(OVERSIZED_DIFF) - }) - const commands = new RuntimeGitCommands({ - resolveRuntimeGitTarget: async () => ({ - worktree: { id: 'wt-1', path: '/remote/repo' } as unknown as ResolvedRuntimeGitWorktree, - executionHostId: 'ssh:conn-1' - }), - getRuntimeSettings: () => ({}) as GlobalSettings - }) - const runtime = Object.assign(commands, { - getRuntimeId: () => 'test-runtime' - }) as unknown as OrcaRuntimeService - - const response = await dispatchRemote(runtime, 'git.diff', CASES[0]!.params, 'mobile') - - expect(response).toMatchObject({ ok: false, error: { code: 'diff_too_large' } }) - }) }) diff --git a/src/main/runtime/runtime-file-command-access.ts b/src/main/runtime/runtime-file-command-access.ts index 4bd27d12a89..8ce72510be0 100644 --- a/src/main/runtime/runtime-file-command-access.ts +++ b/src/main/runtime/runtime-file-command-access.ts @@ -22,7 +22,6 @@ import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtim import { isENOENT } from '../ipc/filesystem-path-containment' import { requireRuntimeFileProvider, - runtimeFileRouteForTarget, type RuntimeFileExplorerPath } from './runtime-file-command-target' import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' @@ -91,15 +90,11 @@ export class RuntimeFileCommandAccess { filePath: string, target: { executionHostId: ExecutionHostId } ): Promise { - const route = runtimeFileRouteForTarget(target) let stats: FileStat try { - stats = await requireRuntimeFileProvider(target, this.host, route).stat(filePath) + stats = await requireRuntimeFileProvider(target, this.host).stat(filePath) } catch (error) { - if ( - isENOENT(error) || - (route.kind === 'ssh' && RuntimeFileCommandAccess.isRemoteNotFoundErrorMessage(error)) - ) { + if (isENOENT(error)) { throw new Error(`ENOENT: no such file or directory, open '${filePath}'`) } throw error @@ -190,12 +185,6 @@ export class RuntimeFileCommandAccess { ...(fileStat.ctimeMs === undefined ? {} : { ctime: fileStat.ctimeMs }) } } - - // The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors). - protected static isRemoteNotFoundErrorMessage(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /\bENOENT\b|no such file|not found|does not exist/i.test(message) - } } // Why: name the resolved owner; undefined would let the renderer fall back to the focused server. diff --git a/src/main/runtime/runtime-file-command-target.ts b/src/main/runtime/runtime-file-command-target.ts index 16e95666674..4c8bed27790 100644 --- a/src/main/runtime/runtime-file-command-target.ts +++ b/src/main/runtime/runtime-file-command-target.ts @@ -10,11 +10,7 @@ import type { LocalFilesystemProvider, LocalFilesystemProviderOptions } from '../providers/local-filesystem-provider' -import { - getSshFilesystemProvider, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-filesystem-dispatch' -import type { IFilesystemProvider } from '../providers/types' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } @@ -49,11 +45,8 @@ export type RuntimeFileExplorerPath = { */ export type RuntimeFileRoute = | { kind: 'local'; createProvider: typeof createLocalFilesystemProvider } - /** `provider: null` is "remote and currently unreachable" — never "read it here". */ - | { kind: 'ssh'; connectionId: string; provider: IFilesystemProvider | null } - -/** The remote half of the route, for leaf helpers that only ever run against an SSH host. */ -export type RuntimeFileSshRoute = Extract + /** An SSH target this process does not serve; it fails or reports empty, never reads here. */ + | { kind: 'ssh'; connectionId: string } export function runtimeFileRouteForTarget(target: { executionHostId: ExecutionHostId @@ -63,11 +56,7 @@ export function runtimeFileRouteForTarget(target: { case 'local': return { kind: 'local', createProvider: route.createProvider } case 'ssh': - return { - kind: 'ssh', - connectionId: route.connectionId, - provider: getSshFilesystemProvider(route.connectionId) ?? null - } + return { kind: 'ssh', connectionId: route.connectionId } case 'runtime': throw new ExecutionHostNotDispatchableError(route.hostId) } @@ -76,32 +65,28 @@ export function runtimeFileRouteForTarget(target: { /** What a runtime command supplies for local work; path authorization is always the store's. */ export type RuntimeLocalFileHost = Omit -/** - * A provider for the target's own host. Local authorizes against `host`'s store; an unreachable SSH - * host and a `runtime:` host both throw. - */ +/** The local route for work this process runs itself; `ssh:` and `runtime:` hosts throw. */ +export function requireLocalRuntimeFileRoute(target: { + executionHostId: ExecutionHostId +}): Extract { + const route = runtimeFileRouteForTarget(target) + if (route.kind === 'ssh') { + throw new SshHostNotServedError(route.connectionId) + } + return route +} + +/** A provider for the target's own host, authorized against `host`'s store. */ export function requireRuntimeFileProvider( target: { executionHostId: ExecutionHostId }, - host: RuntimeLocalFileHost, - route: RuntimeFileRoute = runtimeFileRouteForTarget(target) -): LocalFilesystemProvider | IFilesystemProvider { - if (route.kind === 'ssh') { - return requireSshRuntimeFileProvider(route) - } - return route.createProvider({ + host: RuntimeLocalFileHost +): LocalFilesystemProvider { + return requireLocalRuntimeFileRoute(target).createProvider({ requireStore: () => host.requireStore(), resolveAuthorizedPath }) } -/** For the commands whose local arm keeps its own limits or result shape. */ -export function requireSshRuntimeFileProvider(route: RuntimeFileSshRoute): IFilesystemProvider { - if (!route.provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - return route.provider -} - /** * The SSH target id for the leaf helpers that still address a connection by name — watcher release * keys, re-arm registration, remote path stats. `undefined` is `local`; a `runtime:` host throws diff --git a/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts b/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts index a13aaf37bff..c64715e3fbb 100644 --- a/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts +++ b/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts @@ -1,19 +1,12 @@ // @ts-nocheck -- mechanically split class members. import { RuntimeFileCommandsWithWriteTerminalArtifactFile } from './runtime-file-commands-write-terminal-artifact-file' -import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit' import { runtimeFileWatcherLeasesByOwnerAndRoot, runtimeWatcherReleaseKey } from './runtime-file-commands-mobile-file-list-limit' -import type { IFilesystemProvider } from '../providers/types' -import { - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, - getSshFilesystemProvider, - requireSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' import { + requireLocalRuntimeFileRoute, requireRuntimeFileProvider, - runtimeFileRouteForTarget, runtimeFileSshTargetId } from './runtime-file-command-target' import { stat } from 'node:fs/promises' @@ -22,10 +15,6 @@ import { sortDirEntries } from '../../shared/file-name-sort' import { resolveAuthorizedPath } from '../ipc/filesystem-auth' import { watchWindowsRuntimeFileExplorer } from './runtime-file-command-host' import { beginWatcherInstall } from '../ipc/watcher-removal-gate' -import { - armSshFileExplorerWatchRearm, - stopSshFileExplorerWatchRearms -} from './runtime-file-commands-ssh-file-watcher-rearm' import { closeFileExplorerWatcherInWatcherProcess, watchFileExplorerInWatcherProcess @@ -33,34 +22,13 @@ import { import { registerRuntimeFileWatcherRelease } from './runtime-file-watcher-leases' export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanonical extends RuntimeFileCommandsWithWriteTerminalArtifactFile { - protected async assertRemoteTerminalFileGrantPathStillCanonical( - grant: TerminalFileGrant - ): Promise { - if (!grant.connectionId) { - throw new Error('terminal_file_grant_mismatch') - } - const provider = requireSshFilesystemProvider(grant.connectionId) - const canonicalPath = - grant.provenance === 'native-chat' - ? await provider.realpath(grant.absolutePath) - : await this.resolveAllowedRemoteTerminalArtifactPath( - grant.absolutePath, - grant.connectionId - ) - // Why: relay I/O follows symlinks, so re-canonicalize after the remote process can mutate the path. - if (canonicalPath !== grant.absolutePath) { - throw new Error('terminal_file_grant_stale') - } - return provider - } - async readFileExplorerDir( worktreeSelector: string, relativePath: string, options: { followSymlinks?: boolean } = {} ): Promise { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - // Why: re-sort here — the remote relay may be an older build with lexicographic ordering. + // Why: providers do not promise this order, so sort before returning. return sortDirEntries( await requireRuntimeFileProvider(target, this.host).readDir(target.path, options) ) @@ -82,55 +50,7 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni }> => { const finishInstall = beginWatcherInstall(target.path, sshTargetId) try { - // Re-resolved per open: a reconnect mints a fresh provider for the same target. - const route = runtimeFileRouteForTarget(target) - if (route.kind === 'ssh') { - if (!route.provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const provider = route.provider - const isCurrentInitialProvider = (): boolean => - !signal?.aborted && provider === getSshFilesystemProvider(route.connectionId) - let initialCallbacks = { - callback: (events: FsChangeEvent[]) => { - if (isCurrentInitialProvider()) { - callback(events) - } - }, - onTerminalError: (error: Error) => { - if (isCurrentInitialProvider()) { - onTerminalError(error) - } - } - } - try { - // Initial callbacks join the rearm generation after setup succeeds. - const close = await provider.watch( - target.path, - (events) => initialCallbacks.callback(events), - { signal, onTerminalError: (error) => initialCallbacks.onTerminalError(error) } - ) - const rearm = armSshFileExplorerWatchRearm({ - runtimeId: this.host.getRuntimeId(), - connectionId: route.connectionId, - rootPath: target.path, - callback, - onTerminalError, - signal, - initialUnwatch: close, - initialProvider: provider - }) - initialCallbacks = rearm.initialCallbacks - return { unsubscribe: rearm.unsubscribe, rootPaths: [target.path] } - } catch (error) { - initialCallbacks = { - callback: () => undefined, - onTerminalError: () => undefined - } - throw error - } - } - + requireLocalRuntimeFileRoute(target) const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) const rootStats = await stat(rootPath) if (!rootStats.isDirectory()) { @@ -189,9 +109,6 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni forgetFileExplorerWatchersAfterRemoval(rootPath: string, connectionId?: string): void { const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) - // Why: forget() never runs the lease's unsubscribe, so the re-arm would outlive a deleted - // worktree and re-watch it on the next reconnect. - stopSshFileExplorerWatchRearms(key) const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) if (leases) { for (const lease of Array.from(leases)) { diff --git a/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts b/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts index 380b8a98c53..6bb924b3648 100644 --- a/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts +++ b/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts @@ -2,9 +2,8 @@ import { RuntimeFileCommandsWithWriteFileExplorerFile } from './runtime-file-commands-write-file-explorer-file' import { assertRuntimeFileMutationExpectation } from './runtime-file-commands-mobile-file-list-limit' import { - requireRuntimeFileProvider, - requireSshRuntimeFileProvider, - runtimeFileRouteForTarget + requireLocalRuntimeFileRoute, + requireRuntimeFileProvider } from './runtime-file-command-target' import { resolveAuthorizedPath } from '../ipc/filesystem-auth' import { constants, copyFile, mkdir, rm } from 'node:fs/promises' @@ -47,15 +46,8 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti expectedSshTargetId, expectedSshConnectionGeneration ) - const route = runtimeFileRouteForTarget(tempTarget) - // Why: the local arm commits through the resolved (not symlink-preserving) temp and final paths. - if (route.kind === 'ssh') { - const provider = requireSshRuntimeFileProvider(route) - await provider.copy(tempTarget.path, finalTarget.path) - await provider.deletePath(tempTarget.path, false).catch(() => {}) - return { ok: true } - } - + requireLocalRuntimeFileRoute(tempTarget) + // Why: commit through the resolved (not symlink-preserving) temp and final paths. const store = this.host.requireStore() const tempPath = await resolveAuthorizedPath(tempTarget.path, store) const finalPath = await resolveAuthorizedPath(finalTarget.path, store) diff --git a/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts b/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts index 37c519137ba..e6168093595 100644 --- a/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts +++ b/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts @@ -121,8 +121,6 @@ export const runtimeFileWatcherLeasesByOwnerAndRoot = new Map< Set >() -export const sshFileExplorerWatchRearms = new Map void>>() - export const MOBILE_BINARY_EXTENSIONS = new Set([ '.avif', '.bmp', @@ -164,8 +162,7 @@ export type TerminalFileGrant = { id: string worktreeId: string absolutePath: string - provider: 'local' | 'ssh' - connectionId?: string + provider: 'local' clientId?: string expiresAt: number statIdentity: string | null diff --git a/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts b/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts index 100171723be..afd224dacf4 100644 --- a/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts +++ b/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts @@ -9,13 +9,11 @@ import { MOBILE_FILE_READ_MAX_BYTES, RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES, assertPreviewWithinTransportBudget, - previewableBinaryByteLimit, - readPreviewFileWithinCap + previewableBinaryByteLimit } from './runtime-file-commands-mobile-file-list-limit' import { - requireRuntimeFileProvider, - requireSshRuntimeFileProvider, - runtimeFileRouteForTarget + requireLocalRuntimeFileRoute, + requireRuntimeFileProvider } from './runtime-file-command-target' import { readLocalFileRange } from '../ipc/filesystem/local-file-range-read' import { resolveAuthorizedPath } from '../ipc/filesystem-auth' @@ -29,7 +27,6 @@ import type { DocPreviewFileAccessRequest, DocPreviewFileAccessResult } from '../../shared/doc-preview-file-access' -import { readSshFileExplorerChunk } from './ssh-file-explorer-chunk-read' export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanonical { async readFileExplorerPreview( @@ -42,36 +39,8 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES : previewableBinaryByteLimit(maxContentBytes) const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const route = runtimeFileRouteForTarget(target) - // Why: the local arm sizes its read by extension instead of a stat round trip. - if (route.kind === 'ssh') { - const provider = requireSshRuntimeFileProvider(route) - const fileStats = await provider.stat(target.path) - if (fileStats.size > binaryMaxBytes) { - throw new Error('file_too_large') - } - const result = await readPreviewFileWithinCap(provider, target.path, { - maxBinaryBytes: binaryMaxBytes, - maxTextBytes: MOBILE_FILE_READ_MAX_BYTES - }) - // Why: the stat gate sizes base64 binaries; text crosses the wire JSON-escaped (up to 6x), so - // hold it to the same decoded limit the local branch enforces before reading. - if ( - !result.isBinary && - Buffer.byteLength(result.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES - ) { - throw new Error('file_too_large') - } - if ( - result.isBinary && - maxContentBytes !== undefined && - Buffer.byteLength(result.content, 'utf8') > maxContentBytes - ) { - throw new Error('file_too_large') - } - return assertPreviewWithinTransportBudget(result, maxContentBytes) - } - + requireLocalRuntimeFileRoute(target) + // Why: sized by extension instead of a stat round trip. const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] const maxBytes = mimeType ? binaryMaxBytes : MOBILE_FILE_READ_MAX_BYTES @@ -156,16 +125,7 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC length: number ): Promise { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const route = runtimeFileRouteForTarget(target) - if (route.kind === 'ssh') { - const provider = requireSshRuntimeFileProvider(route) - const fileStat = await provider.stat(target.path) - if (fileStat.type === 'directory') { - throw new Error('Cannot download a directory') - } - return readSshFileExplorerChunk(provider, target.path, fileStat.size, offset, length) - } - + requireLocalRuntimeFileRoute(target) const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) return readLocalFileRange(filePath, offset, length) } diff --git a/src/main/runtime/runtime-file-commands-read-mobile-file.ts b/src/main/runtime/runtime-file-commands-read-mobile-file.ts index 8dbfb2008df..c8e97cd80df 100644 --- a/src/main/runtime/runtime-file-commands-read-mobile-file.ts +++ b/src/main/runtime/runtime-file-commands-read-mobile-file.ts @@ -1,15 +1,10 @@ -import type { IFilesystemProvider } from '../providers/types' -import { MOBILE_FILE_READ_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit' import { RuntimeFileCommandAccess } from './runtime-file-command-access' import type { RuntimeFileReadResult } from '../../shared/runtime-types' import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-command-host' import { joinWorktreeRelativePath } from './runtime-relative-paths' import { readLocalMobileFile } from './runtime-file-commands-terminal-file-paths' import { truncateMobileFilePreview } from './runtime-file-commands-terminal-artifact-access' -import { - requireSshRuntimeFileProvider, - runtimeFileRouteForTarget -} from './runtime-file-command-target' +import { requireLocalRuntimeFileRoute } from './runtime-file-command-target' export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandAccess { async readMobileFile( @@ -19,9 +14,8 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandAcc const store = this.host.requireStore() const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const { worktree } = target - const route = runtimeFileRouteForTarget(target) - // Why: an unreachable host fails before path validation, as it always has. - const sshProvider = route.kind === 'ssh' ? requireSshRuntimeFileProvider(route) : undefined + // Why: an `ssh:` host fails before path validation, as it always has. + requireLocalRuntimeFileRoute(target) if (!isSafeMobileRelativePath(relativePath)) { throw new Error('invalid_relative_path') } @@ -30,9 +24,7 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandAcc } const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - const content = sshProvider - ? await this.readRemoteMobileFile(filePath, sshProvider) - : await readLocalMobileFile(filePath, store) + const content = await readLocalMobileFile(filePath, store) const truncated = truncateMobileFilePreview(content) return { @@ -43,20 +35,4 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandAcc byteLength: truncated.byteLength } } - - protected async readRemoteMobileFile( - filePath: string, - provider: IFilesystemProvider - ): Promise { - const fileStat = await provider.stat(filePath) - // Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it. - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - const result = await provider.readFile(filePath) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content - } } diff --git a/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts b/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts index cfd6c935c14..48c9391e030 100644 --- a/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts +++ b/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts @@ -18,14 +18,13 @@ import type { } from './runtime-file-commands-mobile-file-list-limit' import { TERMINAL_FILE_GRANT_TTL_MS } from './runtime-file-commands-mobile-file-list-limit' import type { RuntimeTerminalPathResolution } from '../../shared/runtime-types' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { randomUUID } from 'node:crypto' import { runtimeFileSshTargetId, type ResolvedRuntimeFileTarget } from './runtime-file-command-target' import { requireReachableFilesystemRoute } from '../providers/execution-host-provider-dispatch' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { getConnectionExecutionHostId } from '../../shared/execution-host' export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends RuntimeFileCommandsWithResolveTerminalPath { @@ -35,7 +34,7 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R worktreePath: string }): Promise { if (args.connectionId) { - return this.resolveAllowedRemoteTerminalArtifactPath(args.absolutePath, args.connectionId) + throw new SshHostNotServedError(args.connectionId) } return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath) } @@ -52,20 +51,11 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R worktreeId: string artifactPath: string rejectedAbsolutePath?: string - connectionId?: string clientId?: string readOnly?: boolean provenance?: TerminalFileGrant['provenance'] }): Promise { - let contentDigest: string | null = null - let stats: RuntimeFileStatLike & { isDirectory: () => boolean } - if (args.connectionId) { - stats = await this.statRemoteTerminalPath(args.artifactPath, args.connectionId) - } else { - const local = await this.statLocalTerminalArtifact(args.artifactPath) - stats = local.stats - contentDigest = local.contentDigest - } + const { stats, contentDigest } = await this.statLocalTerminalArtifact(args.artifactPath) const isDirectory = stats.isDirectory() if (!isDirectory && isTerminalArtifactHardLinked(stats)) { return { @@ -81,8 +71,6 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R : this.createTerminalFileGrant({ worktreeId: args.worktreeId, absolutePath: args.artifactPath, - provider: args.connectionId ? 'ssh' : 'local', - connectionId: args.connectionId, clientId: args.clientId, readOnly: args.readOnly === true, provenance: args.provenance ?? 'terminal-output', @@ -107,29 +95,6 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R } } - protected async resolveAllowedRemoteTerminalArtifactPath( - absolutePath: string, - connectionId: string - ): Promise { - const provider = requireSshFilesystemProvider(connectionId) - const roots = ['/tmp', '/private/tmp'] - const providerTempDir = await provider.getTempDir?.().catch(() => null) - if (providerTempDir) { - roots.push(providerTempDir) - } - if (!roots.some((root) => isPathInsideOrEqual(root, absolutePath))) { - return null - } - const [realArtifactPath, ...realRoots] = await Promise.all([ - provider.realpath(absolutePath), - ...roots.map((root) => provider.realpath(root).catch(() => root)) - ]) - // Why: SSH I/O follows symlinks on the relay; grant the canonical target so a /tmp link can't escape the temp boundary. - return realRoots.some((root) => isPathInsideOrEqual(root, realArtifactPath)) - ? realArtifactPath - : null - } - protected async statLocalTerminalPath( absolutePath: string ): Promise boolean }> { @@ -157,8 +122,6 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R protected createTerminalFileGrant(args: { worktreeId: string absolutePath: string - provider: 'local' | 'ssh' - connectionId?: string clientId?: string readOnly?: boolean provenance: TerminalFileGrant['provenance'] @@ -170,8 +133,7 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R id: randomUUID(), worktreeId: args.worktreeId, absolutePath: args.absolutePath, - provider: args.provider, - ...(args.connectionId ? { connectionId: args.connectionId } : {}), + provider: 'local', ...(args.clientId ? { clientId: args.clientId } : {}), expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS, statIdentity: terminalFileStatIdentity(args.stats), @@ -203,7 +165,8 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R if ( grant.worktreeId !== target.worktree.id || grant.absolutePath !== absolutePath || - grant.connectionId !== runtimeFileSshTargetId(target) || + // Why: grants are only minted for local paths, so an `ssh:` target never matches one. + runtimeFileSshTargetId(target) !== undefined || grant.clientId !== clientId ) { throw new Error('terminal_file_grant_mismatch') diff --git a/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts b/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts index ac4aba2e3c8..e357bf9ecad 100644 --- a/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts +++ b/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts @@ -1,6 +1,5 @@ // @ts-nocheck -- mechanically split class members. import { RuntimeFileCommandsWithReadMobileFile } from './runtime-file-commands-read-mobile-file' -import { RuntimeFileCommandAccess as RuntimeFileCommands } from './runtime-file-command-access' import type { RuntimeNativeChatFileContext, RuntimeTerminalPathResolution @@ -18,8 +17,6 @@ import { } from './runtime-file-command-target' import { isSafeMobileRelativePath } from './runtime-file-command-host' import { isENOENT } from '../ipc/filesystem-path-containment' -import type { RuntimeFileStatLike } from './runtime-file-commands-mobile-file-list-limit' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileCommandsWithReadMobileFile { // Resolves a mobile terminal tap to a worktree-relative path; relatives resolve against cwd, else the worktree root. @@ -91,11 +88,9 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma ) { const isDirectory = ( - await requireRuntimeFileProvider( - knownWorkspaceTarget ?? target, - this.host, - ownedRoute - ).stat(absolutePath) + await requireRuntimeFileProvider(knownWorkspaceTarget ?? target, this.host).stat( + absolutePath + ) ).type === 'directory' return { worktree: ownedWorktree.id, @@ -127,7 +122,6 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma return await this.resolveAbsoluteFileGrant({ worktreeId: worktree.id, artifactPath, - connectionId, clientId, readOnly: true, provenance: 'native-chat' @@ -167,15 +161,11 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma worktreeId: worktree.id, artifactPath, rejectedAbsolutePath: absolutePath, - connectionId, clientId }) } catch (error) { // Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing. - if ( - isENOENT(error) || - (ownedRoute.kind === 'ssh' && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) - ) { + if (isENOENT(error)) { return { ...empty, worktree: ownedWorktree.id, @@ -186,14 +176,4 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma throw error } } - - // Leaf helper: only ever reached from a route already resolved to `ssh`, so the id it takes is - // this client's dialable target rather than a repo row's raw `connectionId`. - protected async statRemoteTerminalPath( - absolutePath: string, - connectionId: string - ): Promise boolean }> { - const stats = await requireSshFilesystemProvider(connectionId).stat(absolutePath) - return { ...stats, isDirectory: () => stats.type === 'directory' } - } } diff --git a/src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts b/src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts index 1a33bba10e9..8baa6a6db32 100644 --- a/src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts +++ b/src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts @@ -1,10 +1,7 @@ // @ts-nocheck -- mechanically split class members. import { RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath } from './runtime-file-commands-resolve-allowed-terminal-artifact-path' import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit' -import { - MOBILE_FILE_READ_MAX_BYTES, - assertPreviewWithinTransportBudget -} from './runtime-file-commands-mobile-file-list-limit' +import { assertPreviewWithinTransportBudget } from './runtime-file-commands-mobile-file-list-limit' import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../shared/runtime-types' import { isMobileBinaryPath } from './runtime-file-command-host' import { @@ -63,20 +60,11 @@ export class RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient extends Ru throw new Error('binary_file') } let content: string - if (grant.connectionId) { - const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) - content = await this.readRemoteTerminalArtifactFile( - provider, - grant, - MOBILE_FILE_READ_MAX_BYTES - ) - } else { - const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) - try { - content = await readLocalTerminalArtifactFileFromHandle(handle, grant) - } finally { - await handle.close() - } + const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) + try { + content = await readLocalTerminalArtifactFileFromHandle(handle, grant) + } finally { + await handle.close() } this.refreshTerminalFileGrant(grant) const truncated = truncateMobileFilePreview(content) @@ -103,14 +91,6 @@ export class RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient extends Ru absolutePath, clientId ) - if (grant.connectionId) { - const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) - this.refreshTerminalFileGrant(grant) - return assertPreviewWithinTransportBudget( - await this.readRemoteTerminalArtifactPreview(provider, grant, maxContentBytes), - maxContentBytes - ) - } const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) try { const preview = await readLocalTerminalArtifactPreviewFromHandle( diff --git a/src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.ts b/src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.ts deleted file mode 100644 index 0ff89703493..00000000000 --- a/src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { FsChangeEvent } from '../../shared/filesystem-entry-types' -import type { IFilesystemProvider } from '../providers/types' -import { toRemoteRuntimeClientErrorLike } from '../../shared/remote-runtime-client-error-classification' -import { - runtimeWatcherReleaseKey, - sshFileExplorerWatchRearms -} from './runtime-file-commands-mobile-file-list-limit' -import { - getSshFilesystemProvider, - onSshFilesystemProviderRegistered -} from '../providers/ssh-filesystem-dispatch' - -export function armSshFileExplorerWatchRearm(args: { - runtimeId: string - connectionId: string - rootPath: string - callback: (events: FsChangeEvent[]) => void - onTerminalError: (error: Error) => void - signal?: AbortSignal - initialUnwatch: () => void - initialProvider: Pick -}): { - initialCallbacks: { - callback: (events: FsChangeEvent[]) => void - onTerminalError: (error: Error) => void - } - unsubscribe: () => Promise -} { - const key = runtimeWatcherReleaseKey(args.runtimeId, args.connectionId, args.rootPath) - let currentUnwatch = args.initialUnwatch - let currentProvider = args.initialProvider - let stopped = false - let reinstalling: Promise | null = null - let providerGeneration = 0 - - const reportTerminalError = (error: unknown): void => { - // Connection loss leaves the established stream armed for the next provider. - if (toRemoteRuntimeClientErrorLike(error).code === 'CONNECTION_LOST') { - return - } - args.onTerminalError(error instanceof Error ? error : new Error(String(error))) - } - - const reinstall = async ( - generation: number, - provider: Pick | undefined - ): Promise => { - if (stopped || generation !== providerGeneration || !provider) { - return - } - const isCurrent = (): boolean => - !stopped && - !args.signal?.aborted && - generation === providerGeneration && - provider === getSshFilesystemProvider(args.connectionId) - let nextUnwatch: () => void - try { - nextUnwatch = await provider.watch( - args.rootPath, - (events) => { - if (isCurrent()) { - args.callback(events) - } - }, - { - signal: args.signal, - onTerminalError: (error) => { - if (isCurrent()) { - reportTerminalError(error) - } - } - } - ) - } catch (error) { - if (!isCurrent()) { - return - } - throw error - } - if (!isCurrent()) { - nextUnwatch() - return - } - // Why: a dead transport's handle must never unwatch the replacement transport's root. - if (currentProvider === provider) { - currentUnwatch() - } - currentUnwatch = nextUnwatch - currentProvider = provider - args.callback([{ kind: 'overflow', absolutePath: args.rootPath }]) - } - - const scheduleReinstall = (): void => { - if (stopped) { - return - } - const generation = ++providerGeneration - let attemptProvider: Pick | undefined - // Why: obsolete reconnect attempts cannot terminate the stream or publish a stale refresh. - const attempt = (reinstalling ?? Promise.resolve()) - .then(() => { - attemptProvider = getSshFilesystemProvider(args.connectionId) - return reinstall(generation, attemptProvider) - }) - .catch((error: unknown) => { - if ( - !stopped && - !args.signal?.aborted && - generation === providerGeneration && - attemptProvider === getSshFilesystemProvider(args.connectionId) - ) { - reportTerminalError(error) - } - }) - .finally(() => { - if (reinstalling === attempt) { - reinstalling = null - } - }) - reinstalling = attempt - } - const unsubscribeRearm = onSshFilesystemProviderRegistered((registeredId) => { - if (registeredId === args.connectionId) { - scheduleReinstall() - } - }) - - const stop = (): void => { - stopped = true - unsubscribeRearm() - const rearms = sshFileExplorerWatchRearms.get(key) - rearms?.delete(stop) - if (rearms?.size === 0) { - sshFileExplorerWatchRearms.delete(key) - } - } - const rearms = sshFileExplorerWatchRearms.get(key) ?? new Set<() => void>() - rearms.add(stop) - sshFileExplorerWatchRearms.set(key, rearms) - if (getSshFilesystemProvider(args.connectionId) !== args.initialProvider) { - scheduleReinstall() - } - const isInitialCurrent = (): boolean => - !stopped && - !args.signal?.aborted && - providerGeneration === 0 && - args.initialProvider === getSshFilesystemProvider(args.connectionId) - - return { - initialCallbacks: { - callback: (events) => { - if (isInitialCurrent()) { - args.callback(events) - } - }, - onTerminalError: (error) => { - if (isInitialCurrent()) { - reportTerminalError(error) - } - } - }, - unsubscribe: () => { - stop() - const close = async (): Promise => currentUnwatch() - // Why: awaiting an absent reinstall costs a microtask, and removal gating relies on the - // unwatch being issued on the same turn the lease releases it. - return reinstalling ? reinstalling.catch(() => undefined).then(close) : close() - } - } -} - -export function stopSshFileExplorerWatchRearms(key: string): void { - for (const stop of Array.from(sshFileExplorerWatchRearms.get(key) ?? [])) { - stop() - } -} diff --git a/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts b/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts index 15a1a47a951..479ea2ec59c 100644 --- a/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts +++ b/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts @@ -1,11 +1,6 @@ // @ts-nocheck -- mechanically split class members. import { RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient } from './runtime-file-commands-revoke-terminal-file-grants-for-client' -import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit' -import { - LOCAL_PREVIEWABLE_BINARY_MAX_BYTES, - MOBILE_FILE_READ_MAX_BYTES, - previewableBinaryByteLimit -} from './runtime-file-commands-mobile-file-list-limit' +import { MOBILE_FILE_READ_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit' import { isBinaryBuffer } from '../../shared/binary-buffer' import { isMobileBinaryPath } from './runtime-file-command-host' import { @@ -18,8 +13,6 @@ import { openLocalTerminalArtifactGrant } from './runtime-file-commands-terminal import { chmod, constants, rename, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' -import type { FileStat, IFilesystemProvider } from '../providers/types' -import type { RuntimeFilePreviewResult } from '../../shared/runtime-types' export class RuntimeFileCommandsWithWriteTerminalArtifactFile extends RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient { async writeTerminalArtifactFile( @@ -44,27 +37,6 @@ export class RuntimeFileCommandsWithWriteTerminalArtifactFile extends RuntimeFil if (isMobileBinaryPath(grant.absolutePath)) { throw new Error('binary_file') } - if (grant.connectionId) { - const { provider, fileStat } = await this.assertRemoteTerminalFileGrantFresh(grant) - if (fileStat.type === 'directory') { - throw new Error('Cannot write to a directory') - } - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - if (!provider.writeTerminalArtifact) { - throw new Error('terminal_file_grant_unavailable') - } - const nextStat = await provider.writeTerminalArtifact( - grant.absolutePath, - content, - this.terminalArtifactAccessOptions(grant, MOBILE_FILE_READ_MAX_BYTES) - ) - grant.statIdentity = terminalFileStatIdentity(nextStat) - this.refreshTerminalFileGrant(grant) - return { ok: true } - } - let originalMode: number | null = null const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) try { @@ -114,83 +86,4 @@ export class RuntimeFileCommandsWithWriteTerminalArtifactFile extends RuntimeFil await rm(tempPath, { force: true }).catch(() => {}) } } - - protected async readRemoteTerminalArtifactPreview( - provider: IFilesystemProvider, - grant: TerminalFileGrant, - maxContentBytes: number | undefined - ): Promise { - const binaryMaxBytes = - maxContentBytes === undefined - ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES - : previewableBinaryByteLimit(maxContentBytes) - const preview = await this.readRemoteTerminalArtifact(provider, grant, binaryMaxBytes) - if ( - !preview.isBinary && - Buffer.byteLength(preview.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES - ) { - throw new Error('file_too_large') - } - if ( - preview.isBinary && - maxContentBytes !== undefined && - Buffer.byteLength(preview.content, 'utf8') > maxContentBytes - ) { - throw new Error('file_too_large') - } - return preview - } - - protected async readRemoteTerminalArtifactFile( - provider: IFilesystemProvider, - grant: TerminalFileGrant, - maxBytes: number - ): Promise { - const result = await this.readRemoteTerminalArtifact(provider, grant, maxBytes) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content - } - - protected async readRemoteTerminalArtifact( - provider: IFilesystemProvider, - grant: TerminalFileGrant, - maxBytes: number - ): Promise { - if (!provider.readTerminalArtifact) { - throw new Error('terminal_file_grant_unavailable') - } - return provider.readTerminalArtifact( - grant.absolutePath, - this.terminalArtifactAccessOptions(grant, maxBytes) - ) - } - - protected terminalArtifactAccessOptions( - grant: TerminalFileGrant, - maxBytes: number - ): { expectedRealPath: string; expectedStatIdentity: string | null; maxBytes: number } { - return { - expectedRealPath: grant.absolutePath, - expectedStatIdentity: grant.statIdentity, - maxBytes - } - } - - protected async assertRemoteTerminalFileGrantFreshForRead( - grant: TerminalFileGrant - ): Promise { - const { provider } = await this.assertRemoteTerminalFileGrantFresh(grant) - return provider - } - - protected async assertRemoteTerminalFileGrantFresh( - grant: TerminalFileGrant - ): Promise<{ provider: IFilesystemProvider; fileStat: FileStat }> { - const provider = await this.assertRemoteTerminalFileGrantPathStillCanonical(grant) - const fileStat = await provider.stat(grant.absolutePath) - assertTerminalFileGrantFresh(grant, fileStat) - return { provider, fileStat } - } } diff --git a/src/main/runtime/runtime-file-listing-producer-budgets.test.ts b/src/main/runtime/runtime-file-listing-producer-budgets.test.ts index 59a895d907e..4d275e9ba2d 100644 --- a/src/main/runtime/runtime-file-listing-producer-budgets.test.ts +++ b/src/main/runtime/runtime-file-listing-producer-budgets.test.ts @@ -1,39 +1,14 @@ import { describe, expect, it, vi } from 'vitest' -import { getSshFilesystemProviderMock } from './orca-runtime-files-mock-registry' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { createRuntimeFileCommands, useRuntimeFileCommandsLifecycle } from './orca-runtime-files-test-harness' const { localList } = vi.hoisted(() => ({ localList: vi.fn() })) vi.mock('../ipc/filesystem-list-files', () => ({ listQuickOpenFiles: localList })) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) - -function inventory(count: number) { - const paths = Array.from({ length: count }, (_, index) => `src/file-${index}.ts`) - return vi.fn(async (_root: string, options?: { maxResults?: number }) => - paths.slice(0, options?.maxResults) - ) -} describe('runtime producer listing budgets', () => { useRuntimeFileCommandsLifecycle() - it.each([5000, 5001, 5002])( - 'passes the mobile sentinel budget on SSH for %i paths', - async (count) => { - const listFiles = inventory(count) - getSshFilesystemProviderMock.mockReturnValue({ listFiles }) - const { commands } = createRuntimeFileCommands({ hostId: 'ssh:host' }) - const result = await commands.listMobileFiles('id:wt-1') - expect(listFiles).toHaveBeenCalledWith('/repo', { maxResults: 5001, signal: undefined }) - expect(result.files).toHaveLength(5000) - expect(result.totalCount).toBe(Math.min(count, 5001)) - expect(result.truncated).toBe(count > 5000) - } - ) - it('passes the same sentinel budget before local enumeration', async () => { localList.mockImplementation(async (_root, _store, _excluded, _signal, maxResults) => Array.from({ length: Math.min(5002, maxResults) }, (_, i) => `file-${i}.txt`) @@ -45,19 +20,6 @@ describe('runtime producer listing budgets', () => { expect(result.truncated).toBe(true) }) - it.each([25002, 100000])( - 'keeps late files in an unqualified %i-file SSH inventory', - async (count) => { - const listFiles = inventory(count) - getSshFilesystemProviderMock.mockReturnValue({ listFiles }) - const { commands } = createRuntimeFileCommands({ hostId: 'ssh:host' }) - const files = await commands.listRuntimeFiles('id:wt-1') - expect(files).toHaveLength(count) - expect(files.at(-1)).toBe(`src/file-${count - 1}.ts`) - expect(listFiles.mock.calls[0][1]?.maxResults).toBeUndefined() - } - ) - it('preserves the full local inventory and explicit caller limits', async () => { localList.mockClear() localList.mockImplementation(async (_root, _store, _excluded, _signal, maxResults) => @@ -69,13 +31,12 @@ describe('runtime producer listing budgets', () => { expect(await commands.listRuntimeFiles('id:wt-1', { maxResults: 3 })).toHaveLength(3) }) - it('requests Markdown from its semantic producer, without retaining unrelated paths', async () => { - const listFiles = vi.fn() - const listMarkdownDocuments = vi.fn().mockResolvedValue([]) - getSshFilesystemProviderMock.mockReturnValue({ listFiles, listMarkdownDocuments }) + it('refuses SSH Markdown listings without listing this machine', async () => { + localList.mockClear() const { commands } = createRuntimeFileCommands({ hostId: 'ssh:host' }) - await commands.listRuntimeMarkdownDocuments('id:wt-1') - expect(listMarkdownDocuments).toHaveBeenCalledWith('/repo') - expect(listFiles).not.toHaveBeenCalled() + await expect(commands.listRuntimeMarkdownDocuments('id:wt-1')).rejects.toThrow( + SshHostNotServedError + ) + expect(localList).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/runtime-file-search.ts b/src/main/runtime/runtime-file-search.ts index 15dd288b1fa..0f4db71d1fe 100644 --- a/src/main/runtime/runtime-file-search.ts +++ b/src/main/runtime/runtime-file-search.ts @@ -5,11 +5,8 @@ import { import type { RuntimeFileListResult } from '../../shared/runtime-types' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits' -import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget' import { listQuickOpenFiles } from '../ipc/filesystem-list-files' import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths' -import type { IFilesystemProvider } from '../providers/types' import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-command-host' import { requireRuntimeFileProvider, @@ -28,7 +25,6 @@ import { RuntimeMobileFilePathSearchCache, rankRuntimeMobileFilePaths } from './runtime-mobile-file-path-search' -import { searchRemoteQuickOpenFilePaths } from './runtime-remote-quick-open-search' type RuntimeFileSearchTarget = { worktree: Pick @@ -56,14 +52,10 @@ export class RuntimeFileSearch { const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const { worktree } = target const route = runtimeFileRouteForTarget(target) + // Why: an `ssh:` host lists nothing rather than failing, and never lists this machine. const files = route.kind === 'ssh' - ? await this.listRemoteMobileFiles( - worktree.path, - route.provider, - MOBILE_FILE_LIST_LIMIT + 1, - options.signal - ) + ? [] : await listQuickOpenFiles( worktree.path, store, @@ -105,11 +97,7 @@ export class RuntimeFileSearch { const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => { const listed = route.kind === 'ssh' - ? await this.listRemoteMobileFiles( - worktree.path, - route.provider, - MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 - ) + ? [] : await listQuickOpenFiles( worktree.path, store, @@ -155,36 +143,18 @@ export class RuntimeFileSearch { const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const { worktree } = target const route = runtimeFileRouteForTarget(target) - const quickOpenSearchVersion = - route.kind !== 'ssh' - ? QUICK_OPEN_SEARCH_VERSION - : (await route.provider?.supportsQuickOpenSearch?.({ signal, minimumVersion: 3 })) - ? 3 - : (await route.provider?.supportsQuickOpenSearch?.({ signal, minimumVersion: 2 })) - ? 2 - : (await route.provider?.supportsQuickOpenSearch?.({ signal, minimumVersion: 1 })) - ? 1 - : 0 + // Why: an `ssh:` host reports no matches (version 0) rather than failing the keystroke. + const quickOpenSearchVersion = route.kind === 'ssh' ? 0 : QUICK_OPEN_SEARCH_VERSION const result = - !query.trim() || isQuickOpenQueryTooLarge(query) + !query.trim() || isQuickOpenQueryTooLarge(query) || route.kind === 'ssh' ? { paths: [], totalCount: 0, truncated: false } - : route.kind === 'ssh' - ? await searchRemoteQuickOpenFilePaths( - worktree.path, - route.provider, - query, - limit, - excludePaths, - signal, - options - ) - : await searchHostQuickOpenFilePaths(worktree.path, this.host.requireStore(), { - ...options, - query, - limit, - excludePaths, - signal - }) + : await searchHostQuickOpenFilePaths(worktree.path, this.host.requireStore(), { + ...options, + query, + limit, + excludePaths, + signal + }) return { worktree: worktree.id, rootPath: worktree.path, @@ -231,40 +201,8 @@ export class RuntimeFileSearch { const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const route = runtimeFileRouteForTarget(target) if (route.kind === 'ssh') { - // Why: quick-open listings degrade to empty for an unreachable host rather than throwing. - const provider = route.provider - if (!provider) { - return [] - } - const maxResults = - options.maxResults ?? - (options.maxContentBytes === undefined ? undefined : QUICK_OPEN_LISTING_MAX_RESULTS) - if ( - (options.candidatePaths !== undefined || - options.includeIgnored === false || - options.followSymlinks) && - !(await provider.supportsQuickOpenSearch?.({ - signal: options.signal, - minimumVersion: options.candidatePaths !== undefined ? 3 : 2 - })) - ) { - throw new Error( - options.candidatePaths !== undefined - ? 'Update the remote host to validate Quick Open recent files.' - : 'Update the remote host to use Quick Open listing options.' - ) - } - const files = await provider.listFiles(target.worktree.path, { - excludePaths: options.excludePaths, - ...(options.candidatePaths === undefined ? {} : { candidatePaths: options.candidatePaths }), - includeIgnored: options.includeIgnored, - followSymlinks: options.followSymlinks, - maxResults, - signal: options.signal - }) - return options.maxContentBytes === undefined - ? files - : limitQuickOpenFilesBySerializedBytes(files, options.maxContentBytes) + // Why: quick-open listings degrade to empty for an `ssh:` host rather than throwing. + return [] } return listQuickOpenFiles( target.worktree.path, @@ -277,17 +215,4 @@ export class RuntimeFileSearch { options ) } - - // `null` provider is the caller's "this host is unreachable" answer, not "list it here". - private async listRemoteMobileFiles( - rootPath: string, - provider: IFilesystemProvider | null, - maxResults?: number, - signal?: AbortSignal - ): Promise { - if (!provider) { - return [] - } - return provider.listFiles(rootPath, { maxResults, signal }) - } } diff --git a/src/main/runtime/runtime-file-target-execution-host.test.ts b/src/main/runtime/runtime-file-target-execution-host.test.ts index 5064fdba011..02b6c657732 100644 --- a/src/main/runtime/runtime-file-target-execution-host.test.ts +++ b/src/main/runtime/runtime-file-target-execution-host.test.ts @@ -1,9 +1,9 @@ // `resolveRuntimeFileTarget` read `store.getRepo(worktree.repoId)?.connectionId` and never looked // at `worktree.hostId`, so one arbitrarily chosen row decided the execution host for ~30 downstream // filesystem dispatches. `undefined` there meant "runtime host", "unresolved" and "genuinely local" -// at once (#11163). These cases pin all four answers end to end, through the real SSH filesystem -// provider table. Companion to runtime-git-target-execution-host.test.ts. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// at once (#11163). These cases pin all four answers end to end; an `ssh:` host is refused by +// name. Companion to runtime-git-target-execution-host.test.ts. +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -22,10 +22,7 @@ vi.mock('../ipc/markdown-documents', async () => ({ })) import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { OrcaRuntimeService } from './orca-runtime' const REMOTE_PATH = '/srv/app-feature' @@ -63,36 +60,25 @@ function makeRuntime(repos: readonly Record[], hostId?: string) return runtime } -function stubProvider() { - return { listMarkdownDocuments: vi.fn().mockResolvedValue([]) } +async function expectRefusedFor(promise: Promise, connectionId: string): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught + ) + expect(error).toBeInstanceOf(SshHostNotServedError) + expect(error).toMatchObject({ connectionId }) + expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled() } describe('runtime file target execution host', () => { - const registered: string[] = [] - - function register(connectionId: string) { - const provider = stubProvider() - registerSshFilesystemProvider(connectionId, provider as never) - registered.push(connectionId) - return provider - } - beforeEach(() => { vi.restoreAllMocks() mocks.listMarkdownDocuments.mockReset().mockResolvedValue([]) }) - afterEach(() => { - for (const connectionId of registered.splice(0)) { - unregisterSshFilesystemProvider(connectionId) - } - }) - - // The case whose absence let the original cross-host leak through review: two SSH hosts - // registered at once, and the rival row is the one `getRepo` returns first. - it('lists an ssh workspace from the host it names, not from a rival row on another ssh host', async () => { - const openclaw = register('openclaw') - const m4air = register('m4air') + // The case whose absence let the original cross-host leak through review: two SSH hosts, + // and the rival row is the one `getRepo` returns first. + it('routes an ssh workspace to the host it names, not to a rival row on another ssh host', async () => { const runtime = makeRuntime( [ { id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }, @@ -101,31 +87,21 @@ describe('runtime file target execution host', () => { 'ssh:m4air' ) - await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`) - - expect(m4air.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH) - expect(openclaw.listMarkdownDocuments).not.toHaveBeenCalled() - expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled() + await expectRefusedFor(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`), 'm4air') }) it("routes to the workspace's host even when the only repo row names a different ssh host", async () => { - const openclaw = register('openclaw') - const m4air = register('m4air') const runtime = makeRuntime( [{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }], 'ssh:m4air' ) - await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`) - - expect(m4air.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH) - expect(openclaw.listMarkdownDocuments).not.toHaveBeenCalled() + await expectRefusedFor(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`), 'm4air') }) // `local` has no SSH namespace to nest in, so a surviving `connectionId` is a row contradicting // itself. The old shape handed it out and read a local workspace off a remote host. it('ignores a stale connection on a row that declares itself local', async () => { - const m4air = register('m4air') const runtime = makeRuntime( [ { @@ -140,15 +116,11 @@ describe('runtime file target execution host', () => { await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`) - expect(m4air.listMarkdownDocuments).not.toHaveBeenCalled() expect(mocks.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH, {}) }) - // A `runtime:` row's `connectionId` names a target in the *server's* namespace. Reading it here - // reaches a same-named target on this client — a silent-wrong-host answer, worse than the - // silent-local one it replaced. - it('refuses a runtime host whose nested ssh target is also registered on this client', async () => { - const impostor = register('nested-1') + // A `runtime:` row's `connectionId` names a target in the *server's* namespace, never this client's. + it('refuses a runtime host whose row carries a nested ssh target', async () => { const runtime = makeRuntime( [ { @@ -164,7 +136,6 @@ describe('runtime file target execution host', () => { await expect(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)).rejects.toThrow( ExecutionHostNotDispatchableError ) - expect(impostor.listMarkdownDocuments).not.toHaveBeenCalled() expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled() }) @@ -187,7 +158,6 @@ describe('runtime file target execution host', () => { }) it('refuses rather than guessing when rival rows disagree and the workspace names no host', async () => { - register('m4air') const runtime = makeRuntime([ { id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }, { id: 'repo-shared', path: '/home/me/app' } @@ -199,13 +169,10 @@ describe('runtime file target execution host', () => { expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled() }) - it('still answers from the single row when the workspace names no host', async () => { - const m4air = register('m4air') + it('still routes by the single row when the workspace names no host', async () => { const runtime = makeRuntime([{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }]) - await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`) - - expect(m4air.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH) + await expectRefusedFor(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`), 'm4air') }) // Losing contact with a remote host is never evidence that its files are here @@ -225,8 +192,6 @@ describe('runtime file target execution host', () => { // The mutation guard recomputed the host from `connectionId`, so a client could satisfy a host // expectation the workspace never named. it('rejects a mutation whose expected host is the row rather than the resolved one', async () => { - register('m4air') - register('openclaw') const runtime = makeRuntime( [{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }], 'ssh:m4air' diff --git a/src/main/runtime/runtime-file-watcher-leases.ts b/src/main/runtime/runtime-file-watcher-leases.ts index 6b4a221528d..877eef729b5 100644 --- a/src/main/runtime/runtime-file-watcher-leases.ts +++ b/src/main/runtime/runtime-file-watcher-leases.ts @@ -4,11 +4,9 @@ import { pendingRuntimeFileWatcherUnsubscribes, runtimeFileWatcherLeasesByOwnerAndRoot, runtimeWatcherReleaseKey, - sshFileExplorerWatchRearms, trackRuntimeFileWatcherUnsubscribe } from './runtime-file-commands-mobile-file-list-limit' import { isWatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' -import { stopSshFileExplorerWatchRearms } from './runtime-file-commands-ssh-file-watcher-rearm' export function registerRuntimeFileWatcherRelease( runtimeId: string, @@ -170,8 +168,5 @@ export function _resetRuntimeFileWatcherLeasesForTests(): void { for (const lease of leases) { lease.forget() } - for (const key of Array.from(sshFileExplorerWatchRearms.keys())) { - stopSshFileExplorerWatchRearms(key) - } runtimeFileWatcherLeasesByOwnerAndRoot.clear() } diff --git a/src/main/runtime/runtime-git-branch-compare-admission.test.ts b/src/main/runtime/runtime-git-branch-compare-admission.test.ts index f9d5ffa4cfd..6420dc0e3ea 100644 --- a/src/main/runtime/runtime-git-branch-compare-admission.test.ts +++ b/src/main/runtime/runtime-git-branch-compare-admission.test.ts @@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeGitCommandHost, RuntimeGitTarget } from './runtime-git-command-target' const mocks = vi.hoisted(() => ({ - getBranchCompare: vi.fn(), - getSshGitProvider: vi.fn() + getBranchCompare: vi.fn() })) vi.mock('../git/status', () => ({ @@ -15,11 +14,8 @@ vi.mock('../git/status', () => ({ })) vi.mock('../git/repo', () => ({ getRemoteCommitUrl: vi.fn(), getRemoteFileUrl: vi.fn() })) vi.mock('../git/runner', () => ({ awaitWindowsHostGitEnvironmentReady: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: mocks.getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'provider unavailable' -})) +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { RuntimeGitDiffCommands } from './runtime-git-diff-commands' function makeCommands(overrides: Partial = {}): RuntimeGitDiffCommands { @@ -69,16 +65,12 @@ describe('RuntimeGitDiffCommands branch-compare admission', () => { }) }) - it('forwards background admission to the SSH execution host', async () => { - const getBranchCompare = vi.fn().mockResolvedValue({ summary: {}, entries: [] }) - mocks.getSshGitProvider.mockReturnValue({ getBranchCompare }) + it('refuses an SSH execution host without running git here', async () => { const commands = makeCommands({ executionHostId: 'ssh:conn-1' }) - await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main', 'background') - - expect(getBranchCompare).toHaveBeenCalledWith('C:\\repo', 'origin/main', { - admissionTier: 'background' - }) + await expect( + commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main', 'background') + ).rejects.toThrow(SshHostNotServedError) expect(mocks.getBranchCompare).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/runtime-git-command-target.test.ts b/src/main/runtime/runtime-git-command-target.test.ts index d6af09a820c..20faf452ada 100644 --- a/src/main/runtime/runtime-git-command-target.test.ts +++ b/src/main/runtime/runtime-git-command-target.test.ts @@ -5,7 +5,7 @@ vi.mock('../git/status', () => ({ getStatus })) import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch' import { createLocalGitProvider } from '../providers/local-git-provider' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { localGitOptionsForTarget, requireRuntimeGitProvider, @@ -25,31 +25,8 @@ function target(overrides: Partial): RuntimeGitTarget { } describe('runtime Git target routing', () => { - const registered: string[] = [] - - function register(connectionId: string) { - const provider = { getStatus: async () => ({ entries: [] }) } - registerSshGitProvider(connectionId, provider as never) - registered.push(connectionId) - return provider - } - afterEach(() => { - for (const connectionId of registered.splice(0)) { - unregisterSshGitProvider(connectionId) - } - }) - - it('routes each ssh host to its own provider', () => { - const m4air = register('m4air') - const openclaw = register('openclaw') - - expect(runtimeGitRouteForTarget(target({ executionHostId: 'ssh:m4air' }))).toEqual({ - kind: 'ssh', - connectionId: 'm4air', - provider: m4air - }) - expect(requireRuntimeGitProvider(target({ executionHostId: 'ssh:openclaw' }))).toBe(openclaw) + getStatus.mockReset() }) it('answers `local` with a provider bound to this worktree, built per call', async () => { @@ -68,18 +45,17 @@ describe('runtime Git target routing', () => { }) // Loss of contact is never evidence of locality (docs/reference/ssh-execution-boundary.md). - it('keeps an unreachable ssh host remote instead of degrading it to local', () => { + it('keeps an ssh host remote instead of degrading it to local', () => { const route = runtimeGitRouteForTarget(target({ executionHostId: 'ssh:gone' })) - expect(route).toEqual({ kind: 'ssh', connectionId: 'gone', provider: null }) + expect(route).toEqual({ kind: 'ssh', connectionId: 'gone' }) expect(() => requireRuntimeGitProvider(target({ executionHostId: 'ssh:gone' }))).toThrow( - /Remote connection dropped/ + SshHostNotServedError ) + expect(getStatus).not.toHaveBeenCalled() }) - it('refuses a runtime host even when a same-named target is registered here', () => { - register('nested-1') - + it('refuses a runtime host instead of routing it', () => { expect(() => runtimeGitRouteForTarget(target({ executionHostId: 'runtime:env-a' }))).toThrow( ExecutionHostNotDispatchableError ) diff --git a/src/main/runtime/runtime-git-command-target.ts b/src/main/runtime/runtime-git-command-target.ts index 01c6bcdd4d8..aa692a9a22d 100644 --- a/src/main/runtime/runtime-git-command-target.ts +++ b/src/main/runtime/runtime-git-command-target.ts @@ -9,8 +9,7 @@ import { resolveGitRouteForHost } from '../providers/execution-host-provider-dispatch' import type { createLocalGitProvider, LocalGitProvider } from '../providers/local-git-provider' -import { getSshGitProvider, sshGitProviderMissingError } from '../providers/ssh-git-dispatch' -import type { SshGitProvider } from '../providers/ssh-git-provider' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import type { PullRequestLinkedIssueMeta } from '../source-control/pull-request-linked-issue' import { normalizeRuntimeRelativePath } from './runtime-relative-paths' @@ -62,8 +61,8 @@ export type RuntimeGitCommandHost = { */ export type RuntimeGitRoute = | { kind: 'local'; createProvider: typeof createLocalGitProvider } - /** `provider: null` is "remote and currently unreachable" — never "run it here". */ - | { kind: 'ssh'; connectionId: string; provider: SshGitProvider | null } + /** An SSH target this process does not serve; it fails or reports unavailable, never runs here. */ + | { kind: 'ssh'; connectionId: string } export function runtimeGitRouteForTarget(target: RuntimeGitTarget): RuntimeGitRoute { const route = resolveGitRouteForHost(target.executionHostId) @@ -71,40 +70,31 @@ export function runtimeGitRouteForTarget(target: RuntimeGitTarget): RuntimeGitRo case 'local': return { kind: 'local', createProvider: route.createProvider } case 'ssh': - return { - kind: 'ssh', - connectionId: route.connectionId, - provider: getSshGitProvider(route.connectionId) ?? null - } + return { kind: 'ssh', connectionId: route.connectionId } case 'runtime': throw new ExecutionHostNotDispatchableError(route.hostId) } } -/** A provider for the target's own host; an unreachable SSH host and a `runtime:` host both throw. */ -export function requireRuntimeGitProvider( - target: RuntimeGitTarget, - route: RuntimeGitRoute = runtimeGitRouteForTarget(target) -): LocalGitProvider { +/** The local route for work this process runs itself; `ssh:` and `runtime:` hosts throw. */ +export function requireLocalRuntimeGitRoute( + target: RuntimeGitTarget +): Extract { + const route = runtimeGitRouteForTarget(target) if (route.kind === 'ssh') { - return requireSshRuntimeGitProvider(route) + throw new SshHostNotServedError(route.connectionId) } - return route.createProvider({ + return route +} + +/** A provider for the target's own host; an `ssh:` host and a `runtime:` host both throw. */ +export function requireRuntimeGitProvider(target: RuntimeGitTarget): LocalGitProvider { + return requireLocalRuntimeGitRoute(target).createProvider({ ...localGitOptionsForTarget(target), getSharedLinkPaths: () => (target.repo ? getWorktreeSharedLinkPaths(target.repo) : []) }) } -/** For the few commands whose SSH arm needs relay-only methods. */ -export function requireSshRuntimeGitProvider( - route: Extract -): SshGitProvider { - if (!route.provider) { - throw sshGitProviderMissingError(route.connectionId) - } - return route.provider -} - export function localGitOptionsForTarget(target: RuntimeGitTarget): GitRuntimeOptions { // WSL routing describes *this* machine; no remote host may inherit it. return target.executionHostId === LOCAL_EXECUTION_HOST_ID ? (target.localGitOptions ?? {}) : {} diff --git a/src/main/runtime/runtime-git-diff-commands.ts b/src/main/runtime/runtime-git-diff-commands.ts index da4f6a0805d..549c18c7773 100644 --- a/src/main/runtime/runtime-git-diff-commands.ts +++ b/src/main/runtime/runtime-git-diff-commands.ts @@ -10,16 +10,15 @@ import { normalizeRuntimeRelativePath } from './runtime-relative-paths' import { localGitOptionsForTarget, normalizeRuntimeGitRelativePath, + requireLocalRuntimeGitRoute, requireRuntimeGitProvider, - requireSshRuntimeGitProvider, - runtimeGitRouteForTarget, type RuntimeGitCommandHost } from './runtime-git-command-target' export class RuntimeGitDiffCommands { constructor(private readonly host: RuntimeGitCommandHost) {} - // Why: cap both local and forwarded SSH payloads after execution-host dispatch. + // Why: cap the payload after execution-host dispatch. async getRuntimeGitDiff( worktreeSelector: string, filePath: string, @@ -69,26 +68,7 @@ export class RuntimeGitDiffCommands { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const relativePath = normalizeRuntimeGitRelativePath(filePath) const oldRelativePath = oldPath ? normalizeRuntimeGitRelativePath(oldPath) : undefined - const route = runtimeGitRouteForTarget(target) - if (route.kind === 'ssh') { - const provider = requireSshRuntimeGitProvider(route) - const results = await provider.getBranchDiff(target.worktree.path, compare.mergeBase, { - includePatch: true, - headOid: compare.headOid, - filePath: relativePath, - oldPath: oldRelativePath - }) - return assertGitDiffWithinTransportBudget( - results[0] ?? { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - }, - maxContentBytes - ) - } + requireLocalRuntimeGitRoute(target) return assertGitDiffWithinTransportBudget( await getBranchDiff( target.worktree.path, diff --git a/src/main/runtime/runtime-git-generation-admission.test.ts b/src/main/runtime/runtime-git-generation-admission.test.ts index 8e8391c10f3..cea5094fe95 100644 --- a/src/main/runtime/runtime-git-generation-admission.test.ts +++ b/src/main/runtime/runtime-git-generation-admission.test.ts @@ -8,7 +8,6 @@ const mocks = vi.hoisted(() => ({ gitExecFileAsync: vi.fn(), getPullRequestDraftContext: vi.fn(), loadPullRequestLinkedIssue: vi.fn(), - getSshGitProvider: vi.fn(), prepareLocalCommitMessageAgentEnv: vi.fn(), generateCommitMessageFromContext: vi.fn(), generatePullRequestFieldsFromContext: vi.fn(), @@ -29,10 +28,6 @@ vi.mock('../source-control/pull-request-linked-issue', () => ({ return linkedLookup.run() } })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: mocks.getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'provider unavailable' -})) vi.mock('../text-generation/commit-message-agent-environment', () => ({ prepareLocalCommitMessageAgentEnv: mocks.prepareLocalCommitMessageAgentEnv })) @@ -44,6 +39,7 @@ vi.mock('../source-control/pull-request-template', () => ({ resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration })) +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' import { RuntimeGitGenerationCommands } from './runtime-git-generation-commands' const params = { agentId: 'codex' as const, model: 'gpt-5.5' } @@ -140,49 +136,29 @@ describe('RuntimeGitGenerationCommands admission', () => { }) expect(mocks.loadPullRequestLinkedIssue).toHaveBeenCalledWith( expect.objectContaining({ - connectionId: undefined, localGitOptions: { wslDistro: 'Ubuntu', admissionTier: 'interactive' } }) ) }) - it('keeps SSH pull-request context reads on the remote provider', async () => { - const exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - mocks.getSshGitProvider.mockReturnValue({ exec, executeCommitMessagePlan: vi.fn() }) - mocks.getPullRequestDraftContext.mockImplementation(async (execute) => { - await execute(['log', '--oneline']) - await execute(['show-ref', '--verify'], { maxBuffer: 456, timeoutMs: 789 }) - return pullRequestContext - }) - const commands = makeCommands( - makeTarget('/remote/repo', { - executionHostId: 'ssh:conn-1', - localGitOptions: { wslDistro: 'Ubuntu' } - }) - ) + it('reports an SSH pull-request target unserved without reading git anywhere', async () => { + const commands = makeCommands(makeTarget('/remote/repo', { executionHostId: 'ssh:conn-1' })) - await commands.generateRuntimePullRequestFields( - 'id:wt-1', - { base: 'main', title: '', body: '', draft: false }, - settingsOverride - ) - - expect(exec).toHaveBeenCalledWith(['log', '--oneline'], '/remote/repo') - expect(exec).toHaveBeenCalledWith(['show-ref', '--verify'], '/remote/repo', { timeoutMs: 789 }) + await expect( + commands.generateRuntimePullRequestFields( + 'id:wt-1', + { base: 'main', title: '', body: '', draft: false }, + settingsOverride + ) + ).resolves.toEqual({ success: false, error: SSH_HOST_NOT_SERVED_MESSAGE }) + expect(mocks.getPullRequestDraftContext).not.toHaveBeenCalled() expect(mocks.gitExecFileAsync).not.toHaveBeenCalled() - expect(mocks.loadPullRequestLinkedIssue).toHaveBeenCalledWith( - expect.objectContaining({ connectionId: 'conn-1', localGitOptions: {} }) - ) + expect(mocks.loadPullRequestLinkedIssue).not.toHaveBeenCalled() }) - describe.each(['local', 'SSH'])('linked-issue lifetime on %s', (host) => { + describe('linked-issue lifetime', () => { function commands(): RuntimeGitGenerationCommands { - mocks.getSshGitProvider.mockReturnValue({ exec: vi.fn(), executeCommitMessagePlan: vi.fn() }) - return makeCommands( - makeTarget('/repo', { - executionHostId: host === 'SSH' ? 'ssh:conn-1' : 'local' - }) - ) + return makeCommands(makeTarget('/repo')) } const input = { base: 'main', title: '', body: '', draft: false, provider: 'gitlab' as const } diff --git a/src/main/runtime/runtime-git-generation-commands.ts b/src/main/runtime/runtime-git-generation-commands.ts index 988e2ee096f..c98cc4fade7 100644 --- a/src/main/runtime/runtime-git-generation-commands.ts +++ b/src/main/runtime/runtime-git-generation-commands.ts @@ -4,7 +4,7 @@ import type { HostedReviewProvider } from '../../shared/hosted-review' import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables' import type { TuiAgent } from '../../shared/tui-agent' import { getStagedCommitContext } from '../git/status' -import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' import { loadPullRequestLinkedIssue } from '../source-control/pull-request-linked-issue' import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment' @@ -12,7 +12,6 @@ import { cancelGenerateCommitMessageLocal, cancelGeneratePullRequestFieldsLocal, discoverCommitMessageModelsLocal, - discoverCommitMessageModelsRemote, generateCommitMessageFromContext, generatePullRequestFieldsFromContext, resolveCommitMessageSettings, @@ -65,28 +64,7 @@ export class RuntimeGitGenerationCommands { } if (route.kind === 'ssh') { - const provider = route.provider - if (!provider) { - return { success: false, error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } - } - let context: CommitMessageDraftContext | null - try { - context = await provider.getStagedCommitContext(target.worktree.path) - } catch (error) { - console.error('[runtime-git] Failed to read remote staged commit context:', error) - return { success: false, error: 'Failed to read staged changes.' } - } - if (!context) { - return { success: false, error: 'No staged changes to summarize.' } - } - context = withLinkedIssueDraftContext(context, linkedIssueForTarget(this.host, target)) - return generateCommitMessageFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: target.worktree.path, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) + return { success: false, error: SSH_HOST_NOT_SERVED_MESSAGE } } let context: CommitMessageDraftContext | null @@ -121,10 +99,9 @@ export class RuntimeGitGenerationCommands { async cancelRuntimeGenerateCommitMessage(worktreeSelector: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const route = runtimeGitRouteForTarget(target) + // Why: an `ssh:` cancel is a no-op; the local registry is keyed by path and would abort an + // unrelated generation running here for the same path. if (route.kind === 'ssh') { - // Cancelling an unreachable host is a no-op, not a local cancel: the local registry is keyed - // by path and would abort an unrelated generation running here for the same path. - await route.provider?.cancelGenerateCommitMessage(target.worktree.path, 'commit-message') return { ok: true } } cancelGenerateCommitMessageLocal(target.worktree.path) @@ -164,23 +141,18 @@ export class RuntimeGitGenerationCommands { return { success: false, error: resolvedSettings.error } } - const provider = route.kind === 'ssh' ? route.provider : null - if (route.kind === 'ssh' && !provider) { - return { success: false, error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } + if (route.kind === 'ssh') { + return { success: false, error: SSH_HOST_NOT_SERVED_MESSAGE } } const issueMeta = linkedIssueMetaForTarget(this.host, target) const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ meta: issueMeta, provider: input.provider, repoPath: target.worktree.path, - connectionId: route.kind === 'ssh' ? route.connectionId : undefined, - localGitOptions: - route.kind === 'ssh' - ? {} - : { - ...localGitOptionsForTarget(target), - admissionTier: 'interactive' - } + localGitOptions: { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + } }) void linkedIssueDetailsPromise.catch(() => undefined) let context: Awaited> @@ -188,11 +160,10 @@ export class RuntimeGitGenerationCommands { const currentBody = await resolveHostedReviewBodyForGeneration({ body: input.body, repoPath: target.worktree.path, - connectionId: route.kind === 'ssh' ? route.connectionId : undefined, provider: input.provider, useTemplate: input.useTemplate }) - context = await getPullRequestDraftContext(pullRequestDraftGitExec(target, route), { + context = await getPullRequestDraftContext(pullRequestDraftGitExec(target), { base: input.base, currentTitle: input.title, currentBody, @@ -214,15 +185,6 @@ export class RuntimeGitGenerationCommands { ...(linkedIssueDetails ? { linkedIssueDetails } : {}) } - if (route.kind === 'ssh') { - return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: target.worktree.path, - execute: (plan, cwd, timeoutMs, operation) => - provider!.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) - } const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, this.host.getCommitMessageAgentEnvironment?.(), @@ -242,7 +204,6 @@ export class RuntimeGitGenerationCommands { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const route = runtimeGitRouteForTarget(target) if (route.kind === 'ssh') { - await route.provider?.cancelGenerateCommitMessage(target.worktree.path, 'pull-request-fields') return { ok: true } } cancelGeneratePullRequestFieldsLocal(target.worktree.path) @@ -261,16 +222,7 @@ export class RuntimeGitGenerationCommands { this.host.getRuntimeSettings().agentCmdOverrides?.[typedAgentId] const route = runtimeGitRouteForTarget(target) if (route.kind === 'ssh') { - const provider = route.provider - if (!provider) { - return { success: false, error: `No git provider for connection "${route.connectionId}"` } - } - return discoverCommitMessageModelsRemote( - typedAgentId, - target.worktree.path, - (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), - agentCommandOverride - ) + return { success: false, error: `No git provider for connection "${route.connectionId}"` } } const localEnv = await prepareLocalCommitMessageAgentEnv( typedAgentId, diff --git a/src/main/runtime/runtime-git-generation-context.ts b/src/main/runtime/runtime-git-generation-context.ts index 7da7651ebec..69c904f091e 100644 --- a/src/main/runtime/runtime-git-generation-context.ts +++ b/src/main/runtime/runtime-git-generation-context.ts @@ -9,28 +9,16 @@ import type { SourceControlAiOperation } from '../../shared/source-control-ai-ty import type { CommitMessageAgentRuntimeTarget } from '../text-generation/commit-message-agent-environment' import type { CommitMessageGenerationTarget } from '../text-generation/commit-message-text-generation' import type { PullRequestLinkedIssueMeta } from '../source-control/pull-request-linked-issue' -import { execSshReviewDraft } from '../providers/ssh-review-draft-context' import { localGitOptionsForTarget, type RuntimeGitCommandHost, - type RuntimeGitRoute, type RuntimeGitTarget } from './runtime-git-command-target' type PullRequestDraftGitExec = Parameters[0] -/** Runs the PR draft-context probes on whichever host `route` resolved to. */ -export function pullRequestDraftGitExec( - target: RuntimeGitTarget, - route: RuntimeGitRoute -): PullRequestDraftGitExec { - if (route.kind === 'ssh') { - const provider = route.provider - if (!provider) { - throw new Error('ssh_git_provider_unavailable') - } - return (argv, options) => execSshReviewDraft(provider, argv, target.worktree.path, options) - } +/** Runs the PR draft-context probes on a local target. */ +export function pullRequestDraftGitExec(target: RuntimeGitTarget): PullRequestDraftGitExec { return (argv, options) => gitExecFileAsync(argv, { cwd: target.worktree.path, diff --git a/src/main/runtime/runtime-git-sync-commands.test.ts b/src/main/runtime/runtime-git-sync-commands.test.ts index b34aa171788..d2405bea95b 100644 --- a/src/main/runtime/runtime-git-sync-commands.test.ts +++ b/src/main/runtime/runtime-git-sync-commands.test.ts @@ -6,12 +6,12 @@ import type * as GitStatusModule from '../git/status' import type * as GitForkSyncModule from '../git/fork-sync' import type { ResolvedRuntimeGitWorktree } from './runtime-git-command-target' import { RuntimeGitSyncCommands } from './runtime-git-sync-commands' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' const mocks = vi.hoisted(() => ({ abortMerge: vi.fn(), abortRebase: vi.fn(), commitChanges: vi.fn(), - getSshGitProvider: vi.fn(), gitSyncForkDefaultBranch: vi.fn(), gitFastForward: vi.fn(), gitFetch: vi.fn(), @@ -37,10 +37,6 @@ vi.mock('../git/remote', async () => ({ gitPull: mocks.gitPull })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: mocks.getSshGitProvider -})) - const worktree = { id: 'wt-1', path: '/workspace/repo' @@ -94,17 +90,7 @@ describe('RuntimeGitSyncCommands admission', () => { ) }) - it('keeps remote runtime git actions owned by the SSH provider', async () => { - const provider = { - abortMerge: vi.fn(), - abortRebase: vi.fn(), - commit: vi.fn().mockResolvedValue({ success: true }), - fastForwardBranch: vi.fn(), - fetchRemote: vi.fn(), - syncForkDefaultBranch: vi.fn().mockResolvedValue({ status: 'up-to-date' }), - pullBranch: vi.fn() - } - mocks.getSshGitProvider.mockReturnValue(provider) + it('refuses every runtime git action on an SSH host without running git here', async () => { const commands = new RuntimeGitSyncCommands({ resolveRuntimeGitTarget: async () => ({ worktree, @@ -113,27 +99,19 @@ describe('RuntimeGitSyncCommands admission', () => { getRuntimeSettings: () => ({}) as GlobalSettings }) - await commands.abortRuntimeGitMerge('id:wt-1') - await commands.abortRuntimeGitRebase('id:wt-1') - await commands.fetchRuntimeGit('id:wt-1', pushTarget) - await commands.syncRuntimeGitForkDefaultBranch('id:wt-1', expectedUpstream) - await commands.pullRuntimeGit('id:wt-1', pushTarget) - await commands.fastForwardRuntimeGit('id:wt-1', pushTarget) - await commands.commitRuntimeGit('id:wt-1', 'feat: keep execution remote') - - expect(provider.abortMerge).toHaveBeenCalledWith(worktree.path) - expect(provider.abortRebase).toHaveBeenCalledWith(worktree.path) - expect(provider.fetchRemote).toHaveBeenCalledWith(worktree.path, pushTarget) - expect(provider.syncForkDefaultBranch).toHaveBeenCalledWith(worktree.path, expectedUpstream) - expect(provider.pullBranch).toHaveBeenCalledWith(worktree.path, pushTarget) - expect(provider.fastForwardBranch).toHaveBeenCalledWith(worktree.path, pushTarget) - expect(provider.commit).toHaveBeenCalledWith(worktree.path, 'feat: keep execution remote') + await expect(commands.abortRuntimeGitMerge('id:wt-1')).rejects.toThrow(SshHostNotServedError) + await expect(commands.fetchRuntimeGit('id:wt-1', pushTarget)).rejects.toThrow( + SshHostNotServedError + ) + await expect(commands.pullRuntimeGit('id:wt-1', pushTarget)).rejects.toThrow( + SshHostNotServedError + ) + await expect(commands.commitRuntimeGit('id:wt-1', 'feat: remote')).rejects.toThrow( + SshHostNotServedError + ) expect(mocks.abortMerge).not.toHaveBeenCalled() - expect(mocks.abortRebase).not.toHaveBeenCalled() expect(mocks.gitFetch).not.toHaveBeenCalled() - expect(mocks.gitSyncForkDefaultBranch).not.toHaveBeenCalled() expect(mocks.gitPull).not.toHaveBeenCalled() - expect(mocks.gitFastForward).not.toHaveBeenCalled() expect(mocks.commitChanges).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/runtime-git-sync-commands.ts b/src/main/runtime/runtime-git-sync-commands.ts index b36405c7e7e..e511fff551d 100644 --- a/src/main/runtime/runtime-git-sync-commands.ts +++ b/src/main/runtime/runtime-git-sync-commands.ts @@ -1,16 +1,11 @@ import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' import type { GitUpstreamStatus } from '../../shared/git-status-types' import type { GitPushTarget } from '../../shared/worktree/types' -import { - materializeWorktreePushTargetRemote, - materializeWorktreePushTargetRemoteSsh -} from '../ipc/worktree-remote' +import { materializeWorktreePushTargetRemote } from '../ipc/worktree-remote' import type { LocalGitProvider } from '../providers/local-git-provider' import { localGitOptionsForTarget, requireRuntimeGitProvider, - requireSshRuntimeGitProvider, - runtimeGitRouteForTarget, type RuntimeGitCommandHost, type RuntimeGitTarget } from './runtime-git-command-target' @@ -30,31 +25,16 @@ export class RuntimeGitSyncCommands { target: RuntimeGitTarget, pushTarget: GitPushTarget | undefined ): Promise<{ provider: LocalGitProvider; pushTarget: GitPushTarget | undefined }> { - const route = runtimeGitRouteForTarget(target) - let provider: LocalGitProvider - let materialized: GitPushTarget | undefined - if (route.kind === 'ssh') { - const sshProvider = requireSshRuntimeGitProvider(route) - provider = sshProvider - materialized = pushTarget - ? await materializeWorktreePushTargetRemoteSsh( - sshProvider, - target.worktree.path, - pushTarget - ) - : undefined - } else { - provider = requireRuntimeGitProvider(target, route) - materialized = pushTarget - ? await materializeWorktreePushTargetRemote( - target.worktree.path, - pushTarget, - undefined, - target.repo?.id, - localGitOptionsForTarget(target) - ) - : undefined - } + const provider = requireRuntimeGitProvider(target) + const materialized = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined if (materialized?.remoteCreated) { this.host.persistMaterializedPushTarget?.(target.worktree.id, materialized) } diff --git a/src/main/runtime/runtime-git-target-execution-host.test.ts b/src/main/runtime/runtime-git-target-execution-host.test.ts index e4d21c8b477..fb3d2efd576 100644 --- a/src/main/runtime/runtime-git-target-execution-host.test.ts +++ b/src/main/runtime/runtime-git-target-execution-host.test.ts @@ -1,8 +1,8 @@ // `resolveRuntimeGitTarget` read `store.getRepo(worktree.repoId)?.connectionId` and never looked at // `worktree.hostId`, so one arbitrarily chosen row decided the execution host for ~36 downstream // Git dispatches. `undefined` there meant "runtime host", "unresolved" and "genuinely local" at -// once (#11163). These cases pin all four answers end to end, through the real SSH provider table. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// once (#11163). These cases pin all four answers end to end; an `ssh:` host is refused by name. +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -21,7 +21,7 @@ vi.mock('../git/status', async () => ({ })) import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { OrcaRuntimeService } from './orca-runtime' const REMOTE_PATH = '/srv/app-feature' @@ -50,36 +50,25 @@ function makeRuntime(repos: readonly Record[], hostId?: string) return runtime } -function stubProvider() { - return { getStatus: vi.fn().mockResolvedValue({ entries: [] }) } +async function expectRefusedFor(promise: Promise, connectionId: string): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught + ) + expect(error).toBeInstanceOf(SshHostNotServedError) + expect(error).toMatchObject({ connectionId }) + expect(mocks.getStatus).not.toHaveBeenCalled() } describe('runtime Git target execution host', () => { - const registered: string[] = [] - - function register(connectionId: string) { - const provider = stubProvider() - registerSshGitProvider(connectionId, provider as never) - registered.push(connectionId) - return provider - } - beforeEach(() => { vi.restoreAllMocks() mocks.getStatus.mockReset().mockResolvedValue({ entries: [] }) }) - afterEach(() => { - for (const connectionId of registered.splice(0)) { - unregisterSshGitProvider(connectionId) - } - }) - // The case whose absence let the original cross-host leak through review: two SSH hosts, and the // rival row is the one `getRepo` returns first. - it('serves an ssh worktree from the host it names, not from a rival row on another ssh host', async () => { - const openclaw = register('openclaw') - const m4air = register('m4air') + it('routes an ssh worktree to the host it names, not to a rival row on another ssh host', async () => { const runtime = makeRuntime( [ { id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }, @@ -88,31 +77,21 @@ describe('runtime Git target execution host', () => { 'ssh:m4air' ) - await runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`) - - expect(m4air.getStatus).toHaveBeenCalledWith(REMOTE_PATH) - expect(openclaw.getStatus).not.toHaveBeenCalled() - expect(mocks.getStatus).not.toHaveBeenCalled() + await expectRefusedFor(runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`), 'm4air') }) it("routes to the worktree's host even when the only repo row names a different ssh host", async () => { - const openclaw = register('openclaw') - const m4air = register('m4air') const runtime = makeRuntime( [{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }], 'ssh:m4air' ) - await runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`) - - expect(m4air.getStatus).toHaveBeenCalledWith(REMOTE_PATH) - expect(openclaw.getStatus).not.toHaveBeenCalled() + await expectRefusedFor(runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`), 'm4air') }) // `local` has no SSH namespace to nest in, so a surviving `connectionId` is a row contradicting // itself. The old shape handed it out and dialled a remote host for a local workspace. it('ignores a stale connection on a row that declares itself local', async () => { - const m4air = register('m4air') const runtime = makeRuntime( [ { @@ -127,15 +106,13 @@ describe('runtime Git target execution host', () => { await runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`) - expect(m4air.getStatus).not.toHaveBeenCalled() expect(mocks.getStatus).toHaveBeenCalled() }) // A `runtime:` row's `connectionId` names a target in the *server's* namespace. Dialling it here // reaches a same-named target on this client — a silent-wrong-host answer, worse than the // silent-local one it replaced. - it('refuses a runtime host whose nested ssh target is also registered on this client', async () => { - const impostor = register('nested-1') + it('refuses a runtime host whose row carries a nested ssh target', async () => { const runtime = makeRuntime( [ { @@ -151,7 +128,6 @@ describe('runtime Git target execution host', () => { await expect(runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`)).rejects.toThrow( ExecutionHostNotDispatchableError ) - expect(impostor.getStatus).not.toHaveBeenCalled() expect(mocks.getStatus).not.toHaveBeenCalled() }) @@ -168,7 +144,6 @@ describe('runtime Git target execution host', () => { }) it('refuses rather than guessing when rival rows disagree and the worktree names no host', async () => { - register('m4air') const runtime = makeRuntime([ { id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }, { id: 'repo-shared', path: '/home/me/app' } @@ -180,13 +155,10 @@ describe('runtime Git target execution host', () => { expect(mocks.getStatus).not.toHaveBeenCalled() }) - it('still answers from the single row when the worktree names no host', async () => { - const m4air = register('m4air') + it('still routes by the single row when the worktree names no host', async () => { const runtime = makeRuntime([{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }]) - await runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`) - - expect(m4air.getStatus).toHaveBeenCalledWith(REMOTE_PATH) + await expectRefusedFor(runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`), 'm4air') }) // Losing contact with a remote host is never evidence that its files are here diff --git a/src/main/runtime/runtime-github-worktree-base.ts b/src/main/runtime/runtime-github-worktree-base.ts index b419f099472..f2bc88d0912 100644 --- a/src/main/runtime/runtime-github-worktree-base.ts +++ b/src/main/runtime/runtime-github-worktree-base.ts @@ -11,7 +11,7 @@ import { getLocalProjectGitExecOptions, getLocalProjectGhExecOptions } from '../project-runtime-git-options' -import { requireSshGitProvider } from '../providers/ssh-git-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { isFolderRepo } from '../../shared/repo-kind' import type { Store } from '../persistence' @@ -39,42 +39,24 @@ export async function resolveRuntimeGitHubWorktreeBase( if (isFolderRepo(repo)) { return { error: 'Folder mode does not support creating worktrees.' } } - const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const localGitExecOptions = sshGitProvider - ? undefined - : getLocalProjectGitExecOptions(deps.store, repo) - const localGhOptions = sshGitProvider - ? repo.ghAccount - ? { ghAccount: repo.ghAccount } - : undefined - : getLocalProjectGhExecOptions(deps.store, repo) - const gitExec = sshGitProvider - ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) - : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) + if (repo.connectionId) { + throw new SshHostNotServedError(repo.connectionId) + } + const localGitExecOptions = getLocalProjectGitExecOptions(deps.store, repo) + const localGhOptions = getLocalProjectGhExecOptions(deps.store, repo) + const gitExec = (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions) const resolveRemote = (): Promise => resolveGitHubReviewHeadRemote({ repoPath: repo.path, issueSourcePreference: repo.issueSourcePreference, - connectionId: repo.connectionId ?? null, + connectionId: null, localGitOptions: localGhOptions, gitExec }) const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => - fetchPrHeadTrackingRef( - repo, - sshGitProvider, - remote, - branch, - localGitExecOptions ? { localGitExecOptions } : {} - ) + fetchPrHeadTrackingRef(repo, null, remote, branch, { localGitExecOptions }) const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => - fetchGitHubPullRequestHeadRef( - repo, - sshGitProvider, - remote, - prNumber, - localGitExecOptions ? { localGitExecOptions } : {} - ) + fetchGitHubPullRequestHeadRef(repo, null, remote, prNumber, { localGitExecOptions }) return resolveGitHubPrStartPoint({ repoPath: repo.path, prNumber: args.prNumber, @@ -82,7 +64,7 @@ export async function resolveRuntimeGitHubWorktreeBase( baseRefName: args.baseRefName, isCrossRepository: args.isCrossRepository, issueSourcePreference: repo.issueSourcePreference, - connectionId: repo.connectionId ?? null, + connectionId: null, localGitOptions: localGhOptions, gitExec, fetchRemoteTrackingRef, diff --git a/src/main/runtime/runtime-gitlab-fork-mr-base.ts b/src/main/runtime/runtime-gitlab-fork-mr-base.ts index 5a2579df654..71270798b3f 100644 --- a/src/main/runtime/runtime-gitlab-fork-mr-base.ts +++ b/src/main/runtime/runtime-gitlab-fork-mr-base.ts @@ -5,14 +5,10 @@ import { } from '../../shared/review-head-tracking-ref' import { isTransientReviewHeadFetchError } from '../git/fetch-error-classification' import { fetchGitLabMergeRequestHeadRef } from '../gitlab/mr-head-tracking-ref' -import type { requireSshGitProvider } from '../providers/ssh-git-dispatch' - -type SshGitProvider = ReturnType type GitExec = (args: string[]) => Promise<{ stdout: string }> export async function resolveRuntimeGitLabForkMrBase(args: { repo: Repo - sshGitProvider: SshGitProvider | null remote: string mrIid: number localGitExecOptions?: { cwd: string; wslDistro?: string } @@ -53,7 +49,7 @@ export async function resolveRuntimeGitLabForkMrBase(args: { try { const localRef = await fetchGitLabMergeRequestHeadRef( args.repo, - args.sshGitProvider, + null, args.remote, args.mrIid, args.localGitExecOptions ? { localGitExecOptions: args.localGitExecOptions } : {} diff --git a/src/main/runtime/runtime-gitlab-issue-source-remote.ts b/src/main/runtime/runtime-gitlab-issue-source-remote.ts index bc4a988695f..33490b641b4 100644 --- a/src/main/runtime/runtime-gitlab-issue-source-remote.ts +++ b/src/main/runtime/runtime-gitlab-issue-source-remote.ts @@ -2,20 +2,17 @@ import type { Repo } from '../../shared/repo-types' import { getDefaultRemote } from '../git/repo' import { getProjectRefForRemote } from '../gitlab/client' import { getGlabKnownHosts } from '../gitlab/gl-utils' -import { requireSshGitProvider } from '../providers/ssh-git-dispatch' -import { pickPreferredGitRemote } from '../../shared/preferred-git-remote' export async function resolveRuntimeGitLabIssueSourceRemote( repoPath: string, preference?: Repo['issueSourcePreference'], - connectionId?: string | null, localGitOptions: { wslDistro?: string } = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const knownHosts = await getGlabKnownHosts(null, localGitOptions) const localGitOptionArgs = Object.keys(localGitOptions).length > 0 ? ([localGitOptions] as const) : [] const resolveProject = (remote: string) => - getProjectRefForRemote(repoPath, remote, knownHosts, connectionId, ...localGitOptionArgs) + getProjectRefForRemote(repoPath, remote, knownHosts, null, ...localGitOptionArgs) if (preference === 'origin') { if (await resolveProject('origin')) { return 'origin' @@ -37,10 +34,5 @@ export async function resolveRuntimeGitLabIssueSourceRemote( if (await resolveProject('origin')) { return 'origin' } - if (connectionId) { - const provider = requireSshGitProvider(connectionId) - const { stdout } = await provider.exec(['remote'], repoPath) - return pickPreferredGitRemote(stdout.split('\n')) - } return getDefaultRemote(repoPath, localGitOptions) } diff --git a/src/main/runtime/runtime-gitlab-worktree-base.ts b/src/main/runtime/runtime-gitlab-worktree-base.ts index 37649ec4085..d17f1a81a92 100644 --- a/src/main/runtime/runtime-gitlab-worktree-base.ts +++ b/src/main/runtime/runtime-gitlab-worktree-base.ts @@ -9,7 +9,7 @@ import { getLocalProjectGitExecOptions, getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import { requireSshGitProvider } from '../providers/ssh-git-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import type { Store } from '../persistence' import { resolveRuntimeGitLabForkMrBase } from './runtime-gitlab-fork-mr-base' import { resolveRuntimeGitLabIssueSourceRemote } from './runtime-gitlab-issue-source-remote' @@ -42,16 +42,12 @@ export async function resolveRuntimeGitLabWorktreeBase( if (isFolderRepo(repo)) { return { error: 'Folder mode does not support creating worktrees.' } } - const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const localGitExecOptions = sshGitProvider - ? undefined - : getLocalProjectGitExecOptions(deps.store, repo) - const localWorktreeGitOptions = sshGitProvider - ? {} - : getLocalProjectWorktreeGitOptions(deps.store, repo) - const gitExec = sshGitProvider - ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) - : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) + if (repo.connectionId) { + throw new SshHostNotServedError(repo.connectionId) + } + const localGitExecOptions = getLocalProjectGitExecOptions(deps.store, repo) + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(deps.store, repo) + const gitExec = (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions) let sourceBranch = args.sourceBranch?.trim() ?? '' let targetBranch = args.targetBranch?.trim() ?? '' let isCrossRepository = args.isCrossRepository === true @@ -61,18 +57,17 @@ export async function resolveRuntimeGitLabWorktreeBase( discoveryRemote = await resolveRuntimeGitLabIssueSourceRemote( repo.path, repo.issueSourcePreference, - repo.connectionId ?? null, localWorktreeGitOptions ) } catch (error) { return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } } - const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null, localWorktreeGitOptions) + const knownHosts = await getGlabKnownHosts(null, localWorktreeGitOptions) const projectRef = await getProjectRefForRemote( repo.path, discoveryRemote, knownHosts, - repo.connectionId ?? null, + null, localWorktreeGitOptions ) if (!projectRef) { @@ -83,7 +78,7 @@ export async function resolveRuntimeGitLabWorktreeBase( projectRef, args.mrIid, 'mr', - repo.connectionId ?? null, + null, localWorktreeGitOptions ) if (!item || item.type !== 'mr') { @@ -103,7 +98,6 @@ export async function resolveRuntimeGitLabWorktreeBase( remote = await resolveRuntimeGitLabIssueSourceRemote( repo.path, repo.issueSourcePreference, - repo.connectionId ?? null, localWorktreeGitOptions ) } catch (error) { @@ -111,9 +105,7 @@ export async function resolveRuntimeGitLabWorktreeBase( } const compareBaseRef = targetBranch ? `refs/remotes/${remote}/${targetBranch}` : undefined const fetchRemoteTrackingRef = async (branch: string, ref: string): Promise => { - await (sshGitProvider - ? sshGitProvider.fetchRemoteTrackingRef(repo.path, remote, branch, ref) - : gitExec(['fetch', remote, `+refs/heads/${branch}:${ref}`])) + await gitExec(['fetch', remote, `+refs/heads/${branch}:${ref}`]) } const fetchCompareBaseRef = (): Promise => fetchCompareBaseRefWithLocalFallback({ @@ -126,7 +118,6 @@ export async function resolveRuntimeGitLabWorktreeBase( if (isCrossRepository) { return resolveRuntimeGitLabForkMrBase({ repo, - sshGitProvider, remote, mrIid: args.mrIid, localGitExecOptions, diff --git a/src/main/runtime/runtime-preserved-branch-cleanup.ts b/src/main/runtime/runtime-preserved-branch-cleanup.ts index 8ba090cf1ef..99b1ccf6325 100644 --- a/src/main/runtime/runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/runtime-preserved-branch-cleanup.ts @@ -8,12 +8,9 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../shared/executi import { isFolderRepo } from '../../shared/repo-kind' import { forceDeleteLocalBranch } from '../git/worktree' import { gitExecFileAsync } from '../git/runner' -import { - cleanupUnusedWorktreePushTargetRemote, - cleanupUnusedWorktreePushTargetRemoteSsh -} from '../ipc/worktree-remote' +import { cleanupUnusedWorktreePushTargetRemote } from '../ipc/worktree-remote' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import { requireSshGitProvider } from '../providers/ssh-git-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import type { Store } from '../persistence' import { resolveWorktreeRemovalRepoOwner } from '../worktree-removal-repo-owner' import { parseExactWorktreeIdSelector } from './runtime-worktree-selection' @@ -124,34 +121,25 @@ export class RuntimePreservedBranchCleanup { } if (repo.connectionId) { - const provider = requireSshGitProvider(repo.connectionId) - await provider.forceDeletePreservedBranch(repo.path, target.branchName, target.head) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider, - repo.path, - removalTarget.id, - target.pushTarget, - store - ) - } else { - const options = getLocalProjectWorktreeGitOptions(store, repo) - await (Object.keys(options).length > 0 - ? forceDeleteLocalBranch( - repo.path, - target.branchName, - target.head, - (argv, cwd) => gitExecFileAsync(argv, { cwd, ...options }), - options - ) - : forceDeleteLocalBranch(repo.path, target.branchName, target.head)) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - target.pushTarget, - store, - options - ) + throw new SshHostNotServedError(repo.connectionId) } + const options = getLocalProjectWorktreeGitOptions(store, repo) + await (Object.keys(options).length > 0 + ? forceDeleteLocalBranch( + repo.path, + target.branchName, + target.head, + (argv, cwd) => gitExecFileAsync(argv, { cwd, ...options }), + options + ) + : forceDeleteLocalBranch(repo.path, target.branchName, target.head)) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + target.pushTarget, + store, + options + ) this.delete(removalTarget.id, target.hostId) return { deleted: true } } diff --git a/src/main/runtime/runtime-remote-quick-open-search.ts b/src/main/runtime/runtime-remote-quick-open-search.ts deleted file mode 100644 index 58c121110ff..00000000000 --- a/src/main/runtime/runtime-remote-quick-open-search.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { resolveSshQuickOpenDiscoveryOptions } from '../providers/ssh-quick-open-discovery-options' -import type { IFilesystemProvider } from '../providers/types' -import { QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT } from './runtime-file-commands-mobile-file-list-limit' -import { QuickOpenPathRanker } from '../../shared/quick-open-path-search' - -export async function searchRemoteQuickOpenFilePaths( - rootPath: string, - // `null` is "remote and currently unreachable": quick open reports no matches rather than - // failing the keystroke, but it never falls back to searching this machine. - provider: IFilesystemProvider | null, - query: string, - limit: number, - excludePaths?: string[], - signal?: AbortSignal, - options: { - includeIgnored?: boolean - followSymlinks?: boolean - allowLegacyIncludeIgnored?: boolean - } = {} -): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> { - if (!provider) { - return { paths: [], totalCount: 0, truncated: false } - } - const discovery = await resolveSshQuickOpenDiscoveryOptions(provider, options, signal) - if (!(await provider.supportsQuickOpenSearch?.({ signal, minimumVersion: 1 }))) { - // Old relays ignore searchQuery. Keep the compatibility request below the - // 4 MiB frame ceiling even when legacy paths are near the 64 KiB path cap. - const legacyFiles = await provider.listFiles(rootPath, { - excludePaths, - maxResults: QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT, - signal - }) - const ranker = new QuickOpenPathRanker(query, limit) - for (const file of legacyFiles) { - ranker.consider(file) - } - const result = ranker.result() - return { - ...result, - truncated: - legacyFiles.length >= QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT || result.totalCount > limit - } - } - const files = await provider.listFiles(rootPath, { - excludePaths, - maxResults: limit + 1, - ...discovery, - searchQuery: query, - signal - }) - return { - paths: files.slice(0, limit), - totalCount: files.length, - truncated: files.length > limit - } -} diff --git a/src/main/runtime/runtime-repository-hooks-commands.ts b/src/main/runtime/runtime-repository-hooks-commands.ts index 334def348d5..ddfcfbd39dd 100644 --- a/src/main/runtime/runtime-repository-hooks-commands.ts +++ b/src/main/runtime/runtime-repository-hooks-commands.ts @@ -2,19 +2,12 @@ import { getStoredRepoSshConnectionId } from '../repo-execution-host' import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import type { Repo } from '../../shared/repo-types' -import { - getEffectiveHooks, - hasHooksFile, - hasUnrecognizedOrcaYamlKeys, - loadHooks, - parseOrcaYaml -} from '../hooks' +import { getEffectiveHooks, hasHooksFile, hasUnrecognizedOrcaYamlKeys, loadHooks } from '../hooks' import { getDefaultTabCommandTrustContent, getEffectiveSetupRunPolicy } from '../effective-hook-config' import { isENOENT } from '../ipc/filesystem-auth' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { isFolderRepo } from '../../shared/repo-kind' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import { joinWorktreeRelativePath } from './runtime-relative-paths' @@ -29,33 +22,13 @@ export class RuntimeRepositoryHooksCommands { async getRepoHooks(repoSelector: string) { const repo = await this.deps.resolveRepo(repoSelector) const connectionId = getStoredRepoSshConnectionId(repo) + // Why: an `ssh:` repo is not served here; it reports no hooks file. if (connectionId) { - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return { - hasHooksFile: false, - hooks: null, - setupRunPolicy: getEffectiveSetupRunPolicy(repo), - source: null - } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - const hooks = result.isBinary ? null : parseOrcaYaml(result.content) - return { - hasHooksFile: Boolean(hooks), - hooks, - setupRunPolicy: getEffectiveSetupRunPolicy(repo), - source: hooks ? ('orca.yaml' as const) : null, - setupTrust: setupTrust(repo, getDefaultTabCommandTrustContent(hooks)) - } - } catch { - return { - hasHooksFile: false, - hooks: null, - setupRunPolicy: getEffectiveSetupRunPolicy(repo), - source: null - } + return { + hasHooksFile: false, + hooks: null, + setupRunPolicy: getEffectiveSetupRunPolicy(repo), + source: null } } const hasFile = hasHooksFile(repo.path) @@ -77,29 +50,7 @@ export class RuntimeRepositoryHooksCommands { } const connectionId = getStoredRepoSshConnectionId(repo) if (connectionId) { - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return { status: 'error' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - if (result.isBinary) { - return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } - } - return { - status: 'ok' as const, - hasHooks: true, - hooks: parseOrcaYaml(result.content), - mayNeedUpdate: false - } - } catch (error) { - return { - status: isENOENT(error) ? ('ok' as const) : ('error' as const), - hasHooks: false, - hooks: null, - mayNeedUpdate: false - } - } + return { status: 'error' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } } const has = hasHooksFile(repo.path) const hooks = has ? loadHooks(repo.path) : null @@ -118,18 +69,8 @@ export class RuntimeRepositoryHooksCommands { } return inspectSetupScriptImportCandidates(async (relativePath) => { const filePath = joinWorktreeRelativePath(repo.path, relativePath) - const connectionId = getStoredRepoSshConnectionId(repo) - if (connectionId) { - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return null - } - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? null : result.content - } catch { - return null - } + if (getStoredRepoSshConnectionId(repo)) { + return null } try { return await readFile(filePath, 'utf-8') diff --git a/src/main/runtime/runtime-repository-issue-command.test.ts b/src/main/runtime/runtime-repository-issue-command.test.ts index aba55791607..8dea7d06bd9 100644 --- a/src/main/runtime/runtime-repository-issue-command.test.ts +++ b/src/main/runtime/runtime-repository-issue-command.test.ts @@ -5,28 +5,14 @@ import * as issueCommandFile from '../issue-command-file' import { beforeEach, describe, expect, it, vi } from 'vitest' import { RuntimeRepositoryIssueCommand } from './runtime-repository-issue-command' -const mocks = vi.hoisted(() => ({ - localCheck: vi.fn(), - remoteCheck: vi.fn(), - requireGit: vi.fn(), - fs: { - createDir: vi.fn(), - readFile: vi.fn(), - writeFile: vi.fn(), - deletePath: vi.fn() - } -})) +const mocks = vi.hoisted(() => ({ localCheck: vi.fn() })) vi.mock('../git/check-ignored-paths', () => ({ checkIgnoredPaths: mocks.localCheck })) -vi.mock('../providers/ssh-git-dispatch', () => ({ requireSshGitProvider: mocks.requireGit })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: () => mocks.fs -})) -describe('remote issue command ignore rules', () => { +describe('SSH issue command', () => { const repo = { id: 'repo-ssh', - path: '/remote/repo with spaces', + path: '/remote/repo', displayName: 'remote', badgeColor: '#000', addedAt: 0, @@ -39,66 +25,27 @@ describe('remote issue command ignore rules', () => { beforeEach(() => { vi.resetAllMocks() - mocks.requireGit.mockReturnValue({ checkIgnoredPaths: mocks.remoteCheck }) - mocks.remoteCheck.mockResolvedValue([]) - mocks.fs.createDir.mockResolvedValue(undefined) - mocks.fs.writeFile.mockResolvedValue(undefined) - mocks.fs.deletePath.mockResolvedValue(undefined) - mocks.fs.readFile.mockResolvedValue({ content: 'node_modules/\n', isBinary: false }) }) - it('uses the remote ignore rules and leaves .gitignore untouched', async () => { - mocks.remoteCheck.mockResolvedValue(['.orca/issue-command']) + it('reads as no command and never reads this machine', async () => { + const read = vi.spyOn(issueCommandFile, 'readIssueCommand') + await expect(commands.read(repo.id)).resolves.toEqual({ + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '/remote/repo/.orca/issue-command', + source: 'none' + }) + expect(read).not.toHaveBeenCalled() + read.mockRestore() + }) - await commands.write(repo.id, 'local command') - - expect(mocks.requireGit).toHaveBeenCalledWith('conn-1') - expect(mocks.remoteCheck).toHaveBeenCalledWith(repo.path, ['.orca/issue-command']) + it('drops a write instead of writing this machine', async () => { + const write = vi.spyOn(issueCommandFile, 'writeIssueCommand') + await expect(commands.write(repo.id, 'command')).resolves.toEqual({ ok: true }) + expect(write).not.toHaveBeenCalled() expect(mocks.localCheck).not.toHaveBeenCalled() - expect(mocks.fs.readFile).not.toHaveBeenCalled() - expect(mocks.fs.writeFile).toHaveBeenCalledExactlyOnceWith( - `${repo.path}/.orca/issue-command`, - 'local command\n' - ) - }) - - it('adds the rule if the remote host does not ignore .orca', async () => { - await commands.write(repo.id, 'local command') - - expect(mocks.fs.writeFile).toHaveBeenCalledWith( - `${repo.path}/.gitignore`, - 'node_modules/\n.orca\n' - ) - expect(mocks.localCheck).not.toHaveBeenCalled() - }) - - it.each(['unavailable', 'failed'])( - 'keeps the remote fallback when Git is %s', - async (failure) => { - if (failure === 'unavailable') { - mocks.requireGit.mockImplementation(() => { - throw new Error('remote Git unavailable') - }) - } else { - mocks.remoteCheck.mockRejectedValue(new Error('remote Git failed')) - } - - await expect(commands.write(repo.id, 'local command')).resolves.toEqual({ ok: true }) - - expect(mocks.localCheck).not.toHaveBeenCalled() - expect(mocks.fs.writeFile).toHaveBeenCalledWith( - `${repo.path}/.gitignore`, - 'node_modules/\n.orca\n' - ) - } - ) - - it('does not inspect ignore rules when clearing an override', async () => { - await commands.write(repo.id, ' ') - - expect(mocks.requireGit).not.toHaveBeenCalled() - expect(mocks.fs.writeFile).not.toHaveBeenCalled() - expect(mocks.fs.deletePath).toHaveBeenCalledWith(`${repo.path}/.orca/issue-command`, false) + write.mockRestore() }) }) diff --git a/src/main/runtime/runtime-repository-issue-command.ts b/src/main/runtime/runtime-repository-issue-command.ts index 730dbe69e9d..9b4d5ed3794 100644 --- a/src/main/runtime/runtime-repository-issue-command.ts +++ b/src/main/runtime/runtime-repository-issue-command.ts @@ -1,15 +1,7 @@ import { getStoredRepoSshConnectionId } from '../repo-execution-host' import type { GitRuntimeOptions } from '../git/git-runtime-options' import type { Repo } from '../../shared/repo-types' -import { parseOrcaYaml } from '../hooks' -import { - isIssueCommandIgnoredByGit, - readIssueCommand, - writeIssueCommand -} from '../issue-command-file' -import { isENOENT } from '../ipc/filesystem-auth' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import type { IFilesystemProvider } from '../providers/types' +import { readIssueCommand, writeIssueCommand } from '../issue-command-file' import { isFolderRepo } from '../../shared/repo-kind' import { joinWorktreeRelativePath } from './runtime-relative-paths' @@ -36,29 +28,13 @@ export class RuntimeRepositoryIssueCommand { if (!connectionId) { return readIssueCommand(repo.path) } - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return { - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: issueCommandPath, - source: 'none' as const - } - } - const localContent = await readRemoteOverride(fsProvider, issueCommandPath) - const sharedContent = await readRemoteShared(fsProvider, repo.path) + // Why: an `ssh:` repo is not served here; it reads as having no command. return { - localContent, - sharedContent, - effectiveContent: localContent ?? sharedContent, - localFilePath: issueCommandPath, - source: localContent - ? ('local' as const) - : sharedContent - ? ('shared' as const) - : ('none' as const) + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: joinWorktreeRelativePath(repo.path, '.orca/issue-command'), + source: 'none' as const } } @@ -68,85 +44,10 @@ export class RuntimeRepositoryIssueCommand { if (isFolderRepo(repo)) { return { ok: true } } - const connectionId = getStoredRepoSshConnectionId(repo) - if (!connectionId) { + // Why: an `ssh:` repo is not served here; the write is dropped, as when it was unreachable. + if (!getStoredRepoSshConnectionId(repo)) { await writeIssueCommand(repo.path, content, () => this.deps.getLocalGitArgs(repo)[0] ?? {}) - return { ok: true } } - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return { ok: true } - } - const trimmed = content.trim() - if (!trimmed) { - await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { - if (!isENOENT(error)) { - throw error - } - }) - return { ok: true } - } - await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) - if (!(await isIssueCommandIgnoredByGit(repo.path, connectionId))) { - await ensureRemoteOrcaDirIgnored(fsProvider, repo.path) - } - await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) return { ok: true } } } - -async function readRemoteOverride( - fsProvider: IFilesystemProvider, - issueCommandPath: string -): Promise { - try { - const result = await fsProvider.readFile(issueCommandPath) - return result.isBinary ? null : result.content.trim() || null - } catch { - return null - } -} - -async function readRemoteShared( - fsProvider: IFilesystemProvider, - repoPath: string -): Promise { - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, 'orca.yaml')) - return result.isBinary ? null : parseOrcaYaml(result.content)?.issueCommand?.trim() || null - } catch { - return null - } -} - -async function ensureRemoteOrcaDirIgnored( - fsProvider: IFilesystemProvider, - repoPath: string -): Promise { - const gitignorePath = joinWorktreeRelativePath(repoPath, '.gitignore') - let result: Awaited> - try { - result = await fsProvider.readFile(gitignorePath) - } catch (error) { - if (!isENOENT(error)) { - console.warn('[runtime] Could not inspect remote .gitignore for .orca', error) - return - } - try { - await fsProvider.writeFile(gitignorePath, '.orca\n') - } catch (writeError) { - console.warn('[runtime] Could not update remote .gitignore to exclude .orca', writeError) - } - return - } - if (result.isBinary || /^\.orca\/?$/m.test(result.content)) { - return - } - const separator = result.content.endsWith('\n') ? '' : '\n' - try { - await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) - } catch (writeError) { - console.warn('[runtime] Could not update remote .gitignore to exclude .orca', writeError) - } -} diff --git a/src/main/runtime/runtime-repository-ref-queries.ts b/src/main/runtime/runtime-repository-ref-queries.ts index f1dd04fec75..1931da9bf52 100644 --- a/src/main/runtime/runtime-repository-ref-queries.ts +++ b/src/main/runtime/runtime-repository-ref-queries.ts @@ -1,4 +1,4 @@ -import type { BaseRefSearchResult, Repo } from '../../shared/repo-types' +import type { Repo } from '../../shared/repo-types' import type { RuntimeRepoSearchRefs } from '../../shared/runtime-types' import { isFolderRepo } from '../../shared/repo-kind' import { @@ -6,20 +6,7 @@ import { getRepoSearchRefsProbeLimit, isRepoSearchRefsRequestLimit } from '../../shared/repo-search-limits' -import { - buildSearchBaseRefsArgv, - getBaseRefDefault, - getRemoteCount, - isForEachRefExcludeUnsupportedError, - mergeBaseRefSearchResultGroups, - normalizeRefSearchQuery, - parseAndFilterSearchRefDetails, - parseRemoteCount, - resolveDefaultBaseRefViaExec, - searchBaseRefDetails -} from '../git/repo' -import { getSshGitCapabilityCache } from '../git/git-capability-state' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { getBaseRefDefault, getRemoteCount, searchBaseRefDetails } from '../git/repo' type RuntimeRepositoryRefQueryDependencies = { resolveRepo: (selector: string) => Promise @@ -43,8 +30,9 @@ export class RuntimeRepositoryRefQueries { if (isFolderRepo(repo)) { return { refs: [], truncated: false } } + // Why: an `ssh:` repo is not served here; it reports no refs and never searches this machine. const refDetails = repo.connectionId - ? await this.searchRemote(repo, query, probeLimit, includeQualifiedRefs) + ? [] : await searchBaseRefDetails(repo.path, query, probeLimit, includeQualifiedRefs) return { refs: refDetails.slice(0, effectiveLimit).map((entry) => entry.refName), @@ -64,7 +52,7 @@ export class RuntimeRepositoryRefQueries { return { defaultBaseRef: null, remoteCount: 0 } } if (repo.connectionId) { - return this.getRemoteDefault(repo) + return { defaultBaseRef: null, remoteCount: 0 } } const [defaultBaseRef, remoteCount] = await Promise.all([ getBaseRefDefault(repo.path), @@ -72,103 +60,4 @@ export class RuntimeRepositoryRefQueries { ]) return { defaultBaseRef, remoteCount } } - - private async getRemoteDefault( - repo: Repo - ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { - const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null - if (!provider) { - return { defaultBaseRef: null, remoteCount: 0 } - } - const [defaultBaseRef, remoteCount] = await Promise.all([ - resolveDefaultBaseRefViaExec(async (argv) => { - try { - return await provider.exec(argv, repo.path) - } catch (error) { - if (argv[0] === 'symbolic-ref') { - console.warn('[runtime:repo.baseRefDefault] SSH symbolic-ref failed', { - path: repo.path, - err: error - }) - } - throw error - } - }), - provider - .exec(['remote'], repo.path) - .then((result) => parseRemoteCount(result.stdout)) - .catch((error) => { - console.warn('[runtime:repo.baseRefDefault] SSH git remote count failed', { - path: repo.path, - err: error - }) - return 0 - }) - ]) - return { defaultBaseRef, remoteCount } - } - - private async searchRemote( - repo: Repo, - query: string, - limit: number, - includeQualifiedRefs: boolean - ): Promise { - const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null - if (!provider) { - return [] - } - const normalizedQuery = normalizeRefSearchQuery(query) - try { - const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) - const remotes = remotesResult.stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - const capabilities = getSshGitCapabilityCache(provider) - const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise => { - return capabilities.runWithFallback( - 'for-each-ref-exclude', - async () => - ( - await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: remotes, - patternGroup - }), - repo.path - ) - ).stdout, - async () => - ( - await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: remotes, - patternGroup - }), - repo.path - ) - ).stdout, - isForEachRefExcludeUnsupportedError - ) - } - if (normalizedQuery.split('/').filter((token) => token.length > 0).length > 1) { - const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')]) - return mergeBaseRefSearchResultGroups( - results.map((stdout) => - parseAndFilterSearchRefDetails(stdout, limit, remotes, includeQualifiedRefs) - ), - limit - ) - } - return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes, includeQualifiedRefs) - } catch (error) { - console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', { - path: repo.path, - err: error - }) - return [] - } - } } diff --git a/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts index 748225de225..9350c0bf439 100644 --- a/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts +++ b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts @@ -3,23 +3,12 @@ import type { GitPushTarget } from '../../shared/worktree/types' import type { Repo } from '../../shared/repo-types' import type { Store } from '../persistence' -const { - materializeLocalMock, - materializeSshMock, - getSshGitProviderMock, - getLocalProjectWorktreeGitOptionsMock -} = vi.hoisted(() => ({ +const { materializeLocalMock, getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({ materializeLocalMock: vi.fn(), - materializeSshMock: vi.fn(), - getSshGitProviderMock: vi.fn(), getLocalProjectWorktreeGitOptionsMock: vi.fn() })) vi.mock('../ipc/worktree-remote', () => ({ - materializeWorktreePushTargetRemote: materializeLocalMock, - materializeWorktreePushTargetRemoteSsh: materializeSshMock -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock + materializeWorktreePushTargetRemote: materializeLocalMock })) vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock @@ -51,8 +40,6 @@ describe('triggerTerminalSpawnPushTargetMaterialization', () => { beforeEach(() => { materializeLocalMock.mockReset().mockResolvedValue(undefined) - materializeSshMock.mockReset().mockResolvedValue(undefined) - getSshGitProviderMock.mockReset() getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) }) @@ -64,7 +51,6 @@ describe('triggerTerminalSpawnPushTargetMaterialization', () => { it('is a no-op when there is no push target', () => { triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, undefined, LOCAL_REPO, STORE) expect(materializeLocalMock).not.toHaveBeenCalled() - expect(materializeSshMock).not.toHaveBeenCalled() }) it('is a no-op for a same-repo push target with no remoteUrl', () => { @@ -108,41 +94,14 @@ describe('triggerTerminalSpawnPushTargetMaterialization', () => { { wslDistro: 'Ubuntu' }, 'worktree-1' ) - expect(materializeSshMock).not.toHaveBeenCalled() }) - it('materializes over SSH when the repo has a connectionId and a provider is registered', () => { - const provider = { exec: vi.fn() } - getSshGitProviderMock.mockReturnValue(provider) - const target = forkTarget() - triggerTerminalSpawnPushTargetMaterialization( - WORKTREE_PATH, - target, - SSH_REPO, - STORE, - REPO_ID, - 'worktree-1' - ) - expect(getSshGitProviderMock).toHaveBeenCalledWith('conn-1') - expect(materializeSshMock).toHaveBeenCalledWith( - provider, - WORKTREE_PATH, - target, - STORE, - undefined, - 'worktree-1' - ) + it('is a no-op for an SSH repo, never materializing on this machine', () => { + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), SSH_REPO, STORE) expect(materializeLocalMock).not.toHaveBeenCalled() expect(getLocalProjectWorktreeGitOptionsMock).not.toHaveBeenCalled() }) - it('is a no-op when the SSH connection has dropped (no registered provider)', () => { - getSshGitProviderMock.mockReturnValue(undefined) - triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), SSH_REPO, STORE) - expect(materializeSshMock).not.toHaveBeenCalled() - expect(materializeLocalMock).not.toHaveBeenCalled() - }) - it('falls back to default git options when WSL project runtime resolution throws', () => { getLocalProjectWorktreeGitOptionsMock.mockImplementation(() => { throw new Error('repair-required') diff --git a/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts index 958ccb99d14..c9b683887f2 100644 --- a/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts +++ b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts @@ -1,8 +1,4 @@ -import { getSshGitProvider } from '../providers/ssh-git-dispatch' -import { - materializeWorktreePushTargetRemote, - materializeWorktreePushTargetRemoteSsh -} from '../ipc/worktree-remote' +import { materializeWorktreePushTargetRemote } from '../ipc/worktree-remote' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { GitPushTarget } from '../../shared/worktree/types' import type { Repo } from '../../shared/repo-types' @@ -21,21 +17,18 @@ export function triggerTerminalSpawnPushTargetMaterialization( repoId?: string, worktreeId?: string ): void { - if (!pushTarget?.remoteUrl || pushTarget.remoteCreated) { + // Why: an `ssh:` repo is not served here; its next sync action reports that itself. + if (!pushTarget?.remoteUrl || pushTarget.remoteCreated || repo?.connectionId) { return } - const connectionId = repo?.connectionId ?? undefined - const materialized = connectionId - ? materializeOverSsh(connectionId, worktreePath, pushTarget, store, worktreeId) - : materializeWorktreePushTargetRemote( - worktreePath, - pushTarget, - store, - repoId, - localGitOptionsForTerminalSpawn(store, repo), - worktreeId - ) - materialized.catch((error: unknown) => { + materializeWorktreePushTargetRemote( + worktreePath, + pushTarget, + store, + repoId, + localGitOptionsForTerminalSpawn(store, repo), + worktreeId + ).catch((error: unknown) => { console.warn( `[terminal-spawn] failed to materialize push target remote for ${worktreePath}:`, error @@ -43,28 +36,6 @@ export function triggerTerminalSpawnPushTargetMaterialization( }) } -function materializeOverSsh( - connectionId: string, - worktreePath: string, - pushTarget: GitPushTarget, - store: Store | undefined, - worktreeId: string | undefined -): Promise { - const provider = getSshGitProvider(connectionId) - if (!provider) { - // Why: connection dropped -- the next Orca-driven sync action will retry via its own dispatch. - return Promise.resolve(pushTarget) - } - return materializeWorktreePushTargetRemoteSsh( - provider, - worktreePath, - pushTarget, - store, - undefined, - worktreeId - ) -} - function localGitOptionsForTerminalSpawn( store: Store | undefined, repo: Repo | null | undefined