diff --git a/src/renderer/src/components/editor/editor-panel-content-types.ts b/src/renderer/src/components/editor/editor-panel-content-types.ts
index 1267f4621cd..636b910b77d 100644
--- a/src/renderer/src/components/editor/editor-panel-content-types.ts
+++ b/src/renderer/src/components/editor/editor-panel-content-types.ts
@@ -1,5 +1,23 @@
import type { GitDiffResult } from '../../../../shared/types'
+/**
+ * Thrown when a worktree's host owner is not yet known (the backing repo has
+ * not hydrated). The retry gate treats this as transient so the read recovers
+ * once the SSH connection finishes establishing, instead of latching a local
+ * "access denied" for a remote path (#6648).
+ */
+export const WORKTREE_OWNER_NOT_READY_ERROR =
+ 'Connecting to the remote host… retrying once the workspace is ready.'
+
+/**
+ * Terminal message shown once the owner-not-ready retry budget is exhausted —
+ * the remote host never finished connecting. Truthful (no longer claims it is
+ * still retrying) and points the user at the Retry button, which starts a fresh
+ * budget (#6648).
+ */
+export const WORKTREE_OWNER_UNREACHABLE_ERROR =
+ "Couldn't reach the remote host. Check the connection, then retry."
+
export type FileContent = {
content: string
isBinary: boolean
diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx
index 9c42f87ee27..8a932eb3668 100644
--- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx
+++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
getRuntimeGitDiff: vi.fn(),
getConnectionId: vi.fn(),
getConnectionIdForFile: vi.fn(),
+ isWorktreeConnectionResolved: vi.fn(() => true),
getState: vi.fn()
}))
@@ -35,7 +36,8 @@ vi.mock('@/runtime/runtime-git-client', () => ({
vi.mock('@/lib/connection-context', () => ({
getConnectionId: mocks.getConnectionId,
- getConnectionIdForFile: mocks.getConnectionIdForFile
+ getConnectionIdForFile: mocks.getConnectionIdForFile,
+ isWorktreeConnectionResolved: mocks.isWorktreeConnectionResolved
}))
vi.mock('@/store', () => ({
@@ -130,6 +132,8 @@ describe('useEditorPanelContentState', () => {
mocks.getConnectionId.mockReturnValue(undefined)
mocks.getConnectionIdForFile.mockReset()
mocks.getConnectionIdForFile.mockReturnValue(undefined)
+ mocks.isWorktreeConnectionResolved.mockReset()
+ mocks.isWorktreeConnectionResolved.mockReturnValue(true)
mocks.getState.mockReset()
mocks.getState.mockReturnValue({ settings: null })
})
@@ -177,6 +181,50 @@ describe('useEditorPanelContentState', () => {
)
})
+ it('does not read locally while a remote host worktree owner is still hydrating (#6648)', async () => {
+ const activeFile = createOpenFile({
+ filePath: '/home/user/project/src/index.ts',
+ relativePath: 'src/index.ts',
+ worktreeId: 'repo-ssh::/home/user/project'
+ })
+ // Owner unknown (SSH repo not hydrated): connection unresolved + not ready.
+ mocks.getConnectionIdForFile.mockReturnValue(undefined)
+ mocks.isWorktreeConnectionResolved.mockReturnValue(false)
+
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+
+ await act(async () => {
+ root?.render()
+ })
+
+ // Surfaces a retryable owner-not-ready error instead of a terminal local
+ // "access denied", and never attempts the bad local read.
+ await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.loadError).toBeTruthy())
+ expect(latestFileContents[activeFile.id]?.loadError).not.toMatch(/access denied/i)
+ expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled()
+
+ // The SSH repo finishes hydrating: the worktree owner resolves to its
+ // target. We do NOT bump the reload nonce here — the retry hook must
+ // re-attempt the read on its own once the owner-not-ready error clears.
+ mocks.isWorktreeConnectionResolved.mockReturnValue(true)
+ mocks.getConnectionIdForFile.mockReturnValue('ssh-target-1')
+ mocks.readRuntimeFileContent.mockResolvedValue({ content: 'remote', isBinary: false })
+
+ // Driven purely by the automatic retry (no re-render, no forced reload).
+ await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('remote'), {
+ timeout: 3000
+ })
+ expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filePath: '/home/user/project/src/index.ts',
+ worktreeId: 'repo-ssh::/home/user/project',
+ connectionId: 'ssh-target-1'
+ })
+ )
+ })
+
it('reloads a clean file when its file content reload nonce changes', async () => {
const activeFile = createOpenFile()
mocks.readRuntimeFileContent
diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts
index bd01423f9d3..152b8974f50 100644
--- a/src/renderer/src/components/editor/useEditorPanelContentState.ts
+++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts
@@ -3,7 +3,11 @@
make the hook coordination harder to audit. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { OpenFile } from '@/store/slices/editor'
-import { getConnectionId, getConnectionIdForFile } from '@/lib/connection-context'
+import {
+ getConnectionId,
+ getConnectionIdForFile,
+ isWorktreeConnectionResolved
+} from '@/lib/connection-context'
import { joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client'
@@ -14,7 +18,11 @@ import {
getRuntimeGitDiff,
getRuntimeGitScope
} from '@/runtime/runtime-git-client'
-import type { DiffContent, FileContent } from './editor-panel-content-types'
+import {
+ WORKTREE_OWNER_NOT_READY_ERROR,
+ type DiffContent,
+ type FileContent
+} from './editor-panel-content-types'
import { canUseChangesModeForFile } from './editor-panel-file-mode'
import {
isReloadableSingleFileDiffTab,
@@ -105,13 +113,24 @@ export function useEditorPanelContentState({
fileReadGenerationCounterRef.current = generation
fileReadGenerationRef.current[id] = generation
try {
- const connectionId = getConnectionIdForFile(worktreeId ?? null, filePath) ?? undefined
+ const resolvedConnectionId = getConnectionIdForFile(worktreeId ?? null, filePath)
+ const connectionId = resolvedConnectionId ?? undefined
const restoredOpenFile = openFilesRef.current.find((file) => file.id === id)
const activeSettings = useAppStore.getState().settings
const readSettings = settingsForRuntimeOwner(
activeSettings,
restoredOpenFile?.runtimeEnvironmentId
)
+ if (
+ resolvedConnectionId === undefined &&
+ !readSettings?.activeRuntimeEnvironmentId?.trim() &&
+ !isWorktreeConnectionResolved(worktreeId ?? null)
+ ) {
+ // Why: the backing repo hasn't hydrated yet (SSH still connecting), so
+ // we can't tell local from remote. Reading locally would deny a remote
+ // path with a terminal "access denied" (#6648); fail retryably instead.
+ throw new Error(WORKTREE_OWNER_NOT_READY_ERROR)
+ }
if (restoredOpenFile?.filePath === filePath && restoredOpenFile.relativePath === filePath) {
if (readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId) {
// Why: restored external-file tabs contain client-local absolute
diff --git a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx
new file mode 100644
index 00000000000..5b7f7bc94db
--- /dev/null
+++ b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx
@@ -0,0 +1,203 @@
+// @vitest-environment happy-dom
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
+import type { OpenFile } from '@/store/slices/editor'
+import {
+ WORKTREE_OWNER_NOT_READY_ERROR,
+ WORKTREE_OWNER_UNREACHABLE_ERROR,
+ type FileContent
+} from './editor-panel-content-types'
+import {
+ OWNER_NOT_READY_RETRY_LIMIT,
+ shouldRetryFileLoadError,
+ useEditorPanelFileLoadRetry
+} from './useEditorPanelFileLoadRetry'
+
+// Why: the real setFileContents replaces the map; a key the hook deletes must
+// disappear. A merge (Object.assign) would silently keep a stale loadError.
+function replaceFileContents(
+ target: Record,
+ next: Record
+): void {
+ for (const key of Object.keys(target)) {
+ delete target[key]
+ }
+ Object.assign(target, next)
+}
+
+function makeFile(overrides: Partial = {}): OpenFile {
+ return {
+ id: 'tab-1',
+ filePath: '/home/user/project/src/index.ts',
+ relativePath: 'src/index.ts',
+ worktreeId: 'repo-ssh::/home/user/project',
+ language: 'typescript',
+ isDirty: false,
+ mode: 'edit',
+ ...overrides
+ }
+}
+
+// Drives the real hook with a controllable fileContents store, mirroring how
+// useEditorPanelContentState wires it up.
+function Harness({
+ file,
+ fileContents,
+ attemptsRef,
+ loadFileContent,
+ setFileContents
+}: {
+ file: OpenFile
+ fileContents: Record
+ attemptsRef: { current: Record }
+ loadFileContent: (filePath: string, id: string) => Promise
+ setFileContents: (
+ updater: (prev: Record) => Record
+ ) => void
+}): null {
+ useEditorPanelFileLoadRetry({
+ activeFile: file,
+ fileContents,
+ fileLoadRetryAttemptsRef: attemptsRef,
+ loadFileContent: loadFileContent as never,
+ openFilesRef: { current: [file] },
+ setFileContents: setFileContents as never
+ })
+ return null
+}
+
+describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', () => {
+ let container: HTMLDivElement | null = null
+ let root: Root | null = null
+ let setTimeoutSpy: MockInstance
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ // Run scheduled retries immediately so we can exhaust the budget without
+ // waiting ~2 minutes of real time.
+ setTimeoutSpy = vi.spyOn(window, 'setTimeout').mockImplementation(((fn: () => void) => {
+ fn()
+ return 0 as unknown as ReturnType
+ }) as typeof window.setTimeout)
+ })
+
+ afterEach(() => {
+ if (root) {
+ act(() => root?.unmount())
+ }
+ container?.remove()
+ container = null
+ root = null
+ setTimeoutSpy.mockRestore()
+ vi.useRealTimers()
+ })
+
+ it('classifies retryable vs terminal errors', () => {
+ expect(shouldRetryFileLoadError(WORKTREE_OWNER_NOT_READY_ERROR)).toBe(true)
+ expect(shouldRetryFileLoadError(WORKTREE_OWNER_UNREACHABLE_ERROR)).toBe(false)
+ expect(shouldRetryFileLoadError('Access denied: outside allowed directories')).toBe(false)
+ })
+
+ it('stops after the budget and shows a truthful terminal message, then Retry re-arms', () => {
+ const file = makeFile()
+ const attemptsRef = { current: {} as Record }
+ // The owner never hydrates: every retry re-fails with owner-not-ready.
+ const fileContents: Record = {
+ [file.id]: { content: '', isBinary: false, loadError: WORKTREE_OWNER_NOT_READY_ERROR }
+ }
+ const setFileContents = (
+ updater: (prev: Record) => Record
+ ): void => {
+ replaceFileContents(fileContents, updater(fileContents))
+ }
+ // loadFileContent (the retry callback) clears then re-fails as owner-not-ready.
+ const loadFileContent = vi.fn(async (_filePath: string, id: string) => {
+ fileContents[id] = { content: '', isBinary: false, loadError: WORKTREE_OWNER_NOT_READY_ERROR }
+ })
+
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+
+ // Re-render the hook repeatedly; each render that still sees the
+ // owner-not-ready error schedules (and our spy immediately runs) one retry.
+ for (let i = 0; i < OWNER_NOT_READY_RETRY_LIMIT + 2; i++) {
+ act(() => {
+ root?.render(
+
+ )
+ })
+ if (fileContents[file.id]?.loadError === WORKTREE_OWNER_UNREACHABLE_ERROR) {
+ break
+ }
+ }
+
+ // Budget honored: retried at most the limit, then went terminal.
+ expect(loadFileContent.mock.calls.length).toBeLessThanOrEqual(OWNER_NOT_READY_RETRY_LIMIT)
+ expect(fileContents[file.id]?.loadError).toBe(WORKTREE_OWNER_UNREACHABLE_ERROR)
+
+ // The terminal error is not auto-retried.
+ const callsAfterTerminal = loadFileContent.mock.calls.length
+ act(() => {
+ root?.render(
+
+ )
+ })
+ expect(loadFileContent.mock.calls.length).toBe(callsAfterTerminal)
+
+ // Retry (reloadFileContent) clears the attempt budget for a fresh start.
+ delete attemptsRef.current[file.id]
+ expect(attemptsRef.current[file.id]).toBeUndefined()
+ })
+
+ it('stops immediately once the read succeeds (no terminal message)', () => {
+ const file = makeFile()
+ const attemptsRef = { current: {} as Record }
+ const fileContents: Record = {
+ [file.id]: { content: '', isBinary: false, loadError: WORKTREE_OWNER_NOT_READY_ERROR }
+ }
+ const setFileContents = (
+ updater: (prev: Record) => Record
+ ): void => {
+ replaceFileContents(fileContents, updater(fileContents))
+ }
+ // The repo hydrates on the first retry: the read now succeeds.
+ const loadFileContent = vi.fn(async (_filePath: string, id: string) => {
+ fileContents[id] = { content: 'remote', isBinary: false }
+ })
+
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+
+ act(() => {
+ root?.render(
+
+ )
+ })
+
+ expect(loadFileContent).toHaveBeenCalledTimes(1)
+ expect(fileContents[file.id]?.loadError).toBeUndefined()
+ expect(fileContents[file.id]?.content).toBe('remote')
+ })
+})
diff --git a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts
index d2b4e62b8f8..d62bc33aa15 100644
--- a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts
+++ b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts
@@ -1,8 +1,23 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { OpenFile } from '@/store/slices/editor'
-import type { FileContent } from './editor-panel-content-types'
+import {
+ WORKTREE_OWNER_NOT_READY_ERROR,
+ WORKTREE_OWNER_UNREACHABLE_ERROR,
+ type FileContent
+} from './editor-panel-content-types'
const FILE_LOAD_RETRY_DELAYS_MS = [250, 1000, 2500]
+// Why: a remote host can take a while to finish connecting. The owner-not-ready
+// check is a pure local store read (it throws before any network call until the
+// SSH repo hydrates), so poll it at a steady cadence — but cap the wait so a
+// host that never connects ends in a truthful terminal message instead of
+// retrying forever. ~2 min covers any realistic connect; Retry re-arms it (#6648).
+export const OWNER_NOT_READY_RETRY_DELAY_MS = 750
+export const OWNER_NOT_READY_RETRY_LIMIT = 160
+
+function isOwnerNotReadyError(message: string): boolean {
+ return message === WORKTREE_OWNER_NOT_READY_ERROR
+}
type UseEditorPanelFileLoadRetryParams = {
activeFile: OpenFile | null
@@ -18,7 +33,12 @@ type UseEditorPanelFileLoadRetryParams = {
setFileContents: Dispatch>>
}
-function shouldRetryFileLoadError(message: string): boolean {
+export function shouldRetryFileLoadError(message: string): boolean {
+ // Terminal: the owner-not-ready budget is spent; only an explicit Retry should
+ // restart it, never the automatic backoff.
+ if (message === WORKTREE_OWNER_UNREACHABLE_ERROR) {
+ return false
+ }
const lower = message.toLowerCase()
return (
!lower.includes('access denied') &&
@@ -49,11 +69,35 @@ export function useEditorPanelFileLoadRetry({
) {
return
}
+ const ownerNotReady = isOwnerNotReadyError(activeFileLoadError)
const retryCount = fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] ?? 0
- if (retryCount >= FILE_LOAD_RETRY_DELAYS_MS.length) {
+ const retryLimit = ownerNotReady
+ ? OWNER_NOT_READY_RETRY_LIMIT
+ : FILE_LOAD_RETRY_DELAYS_MS.length
+ if (retryCount >= retryLimit) {
+ // Why: the remote host never finished connecting. Replace the transient
+ // "still connecting" text with a truthful terminal message so it does not
+ // look like it is still retrying; Retry starts a fresh budget (#6648).
+ if (ownerNotReady) {
+ setFileContents((prev) => {
+ if (prev[activeFileLoadRetryId]?.loadError !== activeFileLoadError) {
+ return prev
+ }
+ return {
+ ...prev,
+ [activeFileLoadRetryId]: {
+ content: '',
+ isBinary: false,
+ loadError: WORKTREE_OWNER_UNREACHABLE_ERROR
+ }
+ }
+ })
+ }
return
}
- const delayMs = FILE_LOAD_RETRY_DELAYS_MS[retryCount] ?? FILE_LOAD_RETRY_DELAYS_MS[0]
+ const delayMs = ownerNotReady
+ ? OWNER_NOT_READY_RETRY_DELAY_MS
+ : (FILE_LOAD_RETRY_DELAYS_MS[retryCount] ?? FILE_LOAD_RETRY_DELAYS_MS[0])
fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] = retryCount + 1
const timeoutId = window.setTimeout(() => {
const currentFile = openFilesRef.current.find((file) => file.id === activeFileLoadRetryId)
diff --git a/src/renderer/src/lib/connection-context.test.ts b/src/renderer/src/lib/connection-context.test.ts
index 225ea00fd32..1f0203e18a9 100644
--- a/src/renderer/src/lib/connection-context.test.ts
+++ b/src/renderer/src/lib/connection-context.test.ts
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { Repo } from '../../../shared/types'
import { useAppStore } from '@/store'
-import { getConnectionId, getConnectionIdForFile } from './connection-context'
+import {
+ getConnectionId,
+ getConnectionIdForFile,
+ isWorktreeConnectionResolved
+} from './connection-context'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
const initialState = useAppStore.getInitialState()
@@ -317,6 +321,25 @@ describe('getConnectionId', () => {
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBe('ssh-1')
})
+ it('reports a worktree owner as unresolved until its backing repo hydrates (#6648)', () => {
+ useAppStore.setState({ repos: [], worktreesByRepo: {} })
+ // SSH repo not yet in the store -> owner unknown, must not read locally.
+ expect(isWorktreeConnectionResolved('repo-ssh::/home/neil/repo')).toBe(false)
+
+ useAppStore.setState({
+ repos: [makeRepo({ id: 'repo-ssh', connectionId: 'ssh-1' })],
+ worktreesByRepo: {}
+ })
+ expect(isWorktreeConnectionResolved('repo-ssh::/home/neil/repo')).toBe(true)
+ })
+
+ it('treats null worktrees and folder workspaces as resolved owners', () => {
+ useAppStore.setState({ repos: [], worktreesByRepo: {} })
+ expect(isWorktreeConnectionResolved(null)).toBe(true)
+ // Folder workspaces resolve per-file via getConnectionIdForFile.
+ expect(isWorktreeConnectionResolved(folderWorkspaceKey('folder-workspace-1'))).toBe(true)
+ })
+
it('keeps normalized same-path folder repo ambiguity when resolving files', () => {
const workspaceKey = folderWorkspaceKey('folder-workspace-1')
useAppStore.setState({
diff --git a/src/renderer/src/lib/connection-context.ts b/src/renderer/src/lib/connection-context.ts
index ea8303b0060..61354cd888a 100644
--- a/src/renderer/src/lib/connection-context.ts
+++ b/src/renderer/src/lib/connection-context.ts
@@ -39,6 +39,28 @@ export function getConnectionId(worktreeId: string | null): string | null | unde
return repo.connectionId ?? null
}
+/**
+ * True when we can determine the owning host (local vs. a specific SSH target)
+ * for a worktree. False means the backing repo has not landed in the store yet
+ * — e.g. right after a session restore while the SSH connection is still
+ * establishing. Callers must not fall back to a LOCAL read of a remote path in
+ * that window; doing so denies the path with a terminal "access denied" (#6648).
+ */
+export function isWorktreeConnectionResolved(worktreeId: string | null): boolean {
+ if (!worktreeId) {
+ return true
+ }
+ const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
+ if (parsedWorkspaceKey?.type === 'folder') {
+ // Folder workspaces resolve per-file; treat them as resolved here and let
+ // getConnectionIdForFile decide ownership for the concrete path.
+ return true
+ }
+ // Why: getConnectionId returns undefined only when the backing repo is absent;
+ // any found repo yields a string or null, so this mirrors "repo has hydrated".
+ return getConnectionId(worktreeId) !== undefined
+}
+
export function getConnectionIdForFile(
worktreeId: string | null,
filePath: string
diff --git a/src/renderer/src/runtime/remote-host-file-open-repro.test.ts b/src/renderer/src/runtime/remote-host-file-open-repro.test.ts
new file mode 100644
index 00000000000..410f092fef6
--- /dev/null
+++ b/src/renderer/src/runtime/remote-host-file-open-repro.test.ts
@@ -0,0 +1,174 @@
+/**
+ * Reproduction harness for issue #6648:
+ * "Remote host attempting to view a file results in error"
+ *
+ * On Windows, opening a file to edit it on a remote (SSH) host fails with:
+ * "Error invoking remote method 'fs:readFile': Error: Access denied: path
+ * resolves outside allowed directories."
+ *
+ * That message (PATH_ACCESS_DENIED_MESSAGE) is only produced by the LOCAL
+ * filesystem resolver, which means the remote file path reached the local
+ * read path WITHOUT a connectionId. This harness drives the real renderer
+ * code paths to show how that happens and stays latched:
+ *
+ * 1. A connected SSH repo's connectionId is resolved from state.repos. While
+ * the SSH repo is still hydrating (e.g. right after a session restore on a
+ * slow Windows SSH connect), getConnectionId returns `undefined`.
+ * 2. readRuntimeFileContent treats `undefined`/`null` connectionId identically
+ * and falls back to a LOCAL fs.readFile of the remote POSIX path.
+ * 3. The local resolver denies the remote path -> PATH_ACCESS_DENIED_MESSAGE.
+ * 4. The editor's retry gate refuses to retry "access denied", so the error
+ * latches permanently even after the SSH repo finishes hydrating.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Repo } from '../../../shared/types'
+import { useAppStore } from '@/store'
+import { getConnectionIdForFile, isWorktreeConnectionResolved } from '@/lib/connection-context'
+import {
+ WORKTREE_OWNER_NOT_READY_ERROR,
+ WORKTREE_OWNER_UNREACHABLE_ERROR
+} from '@/components/editor/editor-panel-content-types'
+import { shouldRetryFileLoadError } from '@/components/editor/useEditorPanelFileLoadRetry'
+import { readRuntimeFileContent } from './runtime-file-client'
+
+// Mirrors src/main/ipc/filesystem-auth.ts PATH_ACCESS_DENIED_MESSAGE.
+const PATH_ACCESS_DENIED_MESSAGE =
+ 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
+
+const REMOTE_REPO_ROOT = '/home/user/project'
+const REMOTE_FILE_PATH = '/home/user/project/src/index.ts'
+const SSH_TARGET_ID = 'ssh-target-1'
+const SSH_REPO_ID = 'repo-ssh'
+const REMOTE_WORKTREE_ID = `${SSH_REPO_ID}::${REMOTE_REPO_ROOT}`
+
+const initialState = useAppStore.getInitialState()
+
+const fsReadFile = vi.fn()
+
+/**
+ * Stand-in for the main-process `fs:readFile` IPC handler. It mirrors the real
+ * routing in src/main/ipc/filesystem.ts: when a connectionId is supplied the
+ * read is served by the SSH provider; otherwise it goes through the local
+ * authorized-path resolver, which denies any path outside the local allowed
+ * roots (SSH repo roots are intentionally excluded — see getLocalRepos).
+ */
+function mainProcessReadFile(args: { filePath: string; connectionId?: string }) {
+ if (args.connectionId) {
+ return Promise.resolve({ content: 'export const remote = true\n', isBinary: false })
+ }
+ // Local resolver: the remote POSIX path is not under any local allowed root.
+ return Promise.reject(new Error(PATH_ACCESS_DENIED_MESSAGE))
+}
+
+function makeRepo(overrides: Partial & { id: string }): Repo {
+ return {
+ path: REMOTE_REPO_ROOT,
+ displayName: 'project',
+ badgeColor: '#000',
+ addedAt: 0,
+ ...overrides
+ }
+}
+
+beforeEach(() => {
+ fsReadFile.mockReset()
+ fsReadFile.mockImplementation(mainProcessReadFile)
+ vi.stubGlobal('window', {
+ api: {
+ fs: { readFile: fsReadFile },
+ runtime: { call: vi.fn() },
+ runtimeEnvironments: { call: vi.fn(), subscribe: vi.fn() }
+ }
+ })
+})
+
+afterEach(() => {
+ useAppStore.setState(initialState, true)
+ vi.unstubAllGlobals()
+})
+
+// Replays the editor read path from useEditorPanelContentState.loadFileContent,
+// including the owner-not-ready guard added for #6648.
+async function openRemoteFileInEditor() {
+ const resolvedConnectionId = getConnectionIdForFile(REMOTE_WORKTREE_ID, REMOTE_FILE_PATH)
+ const connectionId = resolvedConnectionId ?? undefined
+ const readSettings: { activeRuntimeEnvironmentId: string | null } = {
+ activeRuntimeEnvironmentId: null
+ }
+ if (
+ resolvedConnectionId === undefined &&
+ !readSettings.activeRuntimeEnvironmentId?.trim() &&
+ !isWorktreeConnectionResolved(REMOTE_WORKTREE_ID)
+ ) {
+ throw new Error(WORKTREE_OWNER_NOT_READY_ERROR)
+ }
+ return readRuntimeFileContent({
+ settings: readSettings,
+ filePath: REMOTE_FILE_PATH,
+ relativePath: 'src/index.ts',
+ worktreeId: REMOTE_WORKTREE_ID,
+ connectionId
+ })
+}
+
+describe('issue #6648: opening a remote-host file in the editor', () => {
+ it('succeeds once the SSH repo is hydrated with its connectionId', async () => {
+ useAppStore.setState({
+ repos: [makeRepo({ id: SSH_REPO_ID, connectionId: SSH_TARGET_ID })],
+ worktreesByRepo: {}
+ })
+
+ await expect(openRemoteFileInEditor()).resolves.toEqual({
+ content: 'export const remote = true\n',
+ isBinary: false
+ })
+ expect(fsReadFile).toHaveBeenCalledWith({
+ filePath: REMOTE_FILE_PATH,
+ connectionId: SSH_TARGET_ID
+ })
+ })
+
+ it('FIXED: fails retryably (not a local access-denied) while the SSH repo hydrates', async () => {
+ // Session restore reopened the remote tab, but the SSH repo has not landed
+ // in state.repos yet (relay/connection still establishing — slower on
+ // Windows). getConnectionIdForFile returns undefined for the unknown repo.
+ useAppStore.setState({ repos: [], worktreesByRepo: {} })
+
+ expect(getConnectionIdForFile(REMOTE_WORKTREE_ID, REMOTE_FILE_PATH)).toBeUndefined()
+ expect(isWorktreeConnectionResolved(REMOTE_WORKTREE_ID)).toBe(false)
+
+ // The owner-not-ready guard prevents the bad local read entirely.
+ await expect(openRemoteFileInEditor()).rejects.toThrow(WORKTREE_OWNER_NOT_READY_ERROR)
+ await expect(openRemoteFileInEditor()).rejects.not.toThrow(PATH_ACCESS_DENIED_MESSAGE)
+ expect(fsReadFile).not.toHaveBeenCalled()
+ })
+
+ it('FIXED: recovers once the SSH repo finishes hydrating', async () => {
+ useAppStore.setState({ repos: [], worktreesByRepo: {} })
+ await expect(openRemoteFileInEditor()).rejects.toThrow(WORKTREE_OWNER_NOT_READY_ERROR)
+
+ // Relay discovery completes and the SSH repo lands in the store.
+ useAppStore.setState({
+ repos: [makeRepo({ id: SSH_REPO_ID, connectionId: SSH_TARGET_ID })],
+ worktreesByRepo: {}
+ })
+
+ await expect(openRemoteFileInEditor()).resolves.toEqual({
+ content: 'export const remote = true\n',
+ isBinary: false
+ })
+ expect(fsReadFile).toHaveBeenCalledWith({
+ filePath: REMOTE_FILE_PATH,
+ connectionId: SSH_TARGET_ID
+ })
+ })
+
+ it('retry gate retries owner-not-ready but not access-denied or the terminal message', () => {
+ // owner-not-ready auto-retries (while connecting)...
+ expect(shouldRetryFileLoadError(WORKTREE_OWNER_NOT_READY_ERROR)).toBe(true)
+ // ...but a genuine access-denied and the budget-exhausted terminal message
+ // are NOT auto-retried (the terminal one only restarts via the Retry button).
+ expect(shouldRetryFileLoadError(PATH_ACCESS_DENIED_MESSAGE)).toBe(false)
+ expect(shouldRetryFileLoadError(WORKTREE_OWNER_UNREACHABLE_ERROR)).toBe(false)
+ })
+})