fix(ssh): open external host images from terminal links (#10323)

* fix(ssh): open external host images from terminal links

* fix(ssh): keep external file ownership host-scoped

* fix(ssh): reject blank external file owners

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Jinwoo Hong 2026-07-24 12:05:38 -07:00 committed by GitHub
parent 3dfbb10775
commit 01929fde40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 785 additions and 18 deletions

View File

@ -389,6 +389,7 @@ export function EditorContent({
content={editorContent}
filePath={activeFile.filePath}
worktreeId={activeFile.worktreeId}
externalSshTargetId={activeFile.externalSshTargetId}
runtimeEnvironmentId={activeFile.runtimeEnvironmentId}
scrollCacheKey={`${editorViewStateKey}:rich`}
onContentChange={onContentChangeWithFm}

View File

@ -91,7 +91,8 @@ export function keepTabEditsOverExternalChange(file: OpenFile): void {
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
expectedExternalSshTargetId: file.externalSshTargetId
})
.then((result) => {
if (result.isBinary) {

View File

@ -60,7 +60,8 @@ export function ExternalFileChangeCompareDialog({
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
expectedExternalSshTargetId: file.externalSshTargetId
})
.then((result) => {
if (cancelled) {
@ -82,7 +83,14 @@ export function ExternalFileChangeCompareDialog({
return () => {
cancelled = true
}
}, [open, file.filePath, file.relativePath, file.worktreeId, file.runtimeEnvironmentId])
}, [
open,
file.filePath,
file.relativePath,
file.worktreeId,
file.runtimeEnvironmentId,
file.externalSshTargetId
])
const language = detectLanguage(file.relativePath)

View File

@ -134,6 +134,7 @@ type MarkdownPreviewSourceOpenFile = {
relativePath: string
worktreeId: string
runtimeEnvironmentId?: string | null
externalSshTargetId?: string
mode: string
markdownPreviewSourceFileId?: string
}
@ -622,12 +623,14 @@ export default function MarkdownPreview({
settings: settingsForRuntimeOwner(settings, resolvedSourceRuntimeEnvironmentId),
worktreeId: sourceRoutingWorktreeId,
worktreePath: worktreeRoot,
connectionId: sourceConnectionId
connectionId: sourceConnectionId,
expectedExternalSshTargetId: sourceOpenFile?.externalSshTargetId
}
: undefined,
[
settings,
sourceConnectionId,
sourceOpenFile?.externalSshTargetId,
resolvedSourceRuntimeEnvironmentId,
sourceRoutingWorktreeId,
worktreeRoot

View File

@ -36,6 +36,7 @@ type RichMarkdownEditorProps = {
content: string
filePath: string
worktreeId: string
externalSshTargetId?: string
runtimeEnvironmentId?: string | null
scrollCacheKey: string
onContentChange: (content: string) => void
@ -60,6 +61,7 @@ export default function RichMarkdownEditor({
content,
filePath,
worktreeId,
externalSshTargetId,
runtimeEnvironmentId,
scrollCacheKey,
onContentChange,
@ -164,6 +166,7 @@ export default function RichMarkdownEditor({
const reconcileRoundTripRef = useRichMarkdownReconcileRoundTrip({
htmlSuperscriptLinkContext,
filePath,
externalSshTargetId,
runtimeEnvironmentId,
worktreeId,
worktreeRoot
@ -220,6 +223,7 @@ export default function RichMarkdownEditor({
filePath,
worktreeId,
worktreeRoot,
externalSshTargetId,
runtimeEnvironmentId,
isMac,
richMarkdownSpellcheckEnabled,
@ -312,6 +316,7 @@ export default function RichMarkdownEditor({
editor,
fileId,
filePath,
externalSshTargetId,
isApplyingProgrammaticUpdateRef,
lastCommittedMarkdownRef,
originalSourceRef,

View File

@ -250,6 +250,27 @@ describe('attachRestoredTabConflictScan', () => {
}
})
it('does not verify an external SSH file through a replacement target', async () => {
const store = createEditorStore()
openRestoredDirtyTab(store, '/tmp/external.ts', 'original baseline')
store.setState({
openFiles: store
.getState()
.openFiles.map((file) => ({ ...file, externalSshTargetId: 'ssh-original' }))
} as never)
mocks.getConnectionIdForFile.mockReturnValue('ssh-replacement')
const detach = attachRestoredTabConflictScan(store)
try {
await vi.advanceTimersByTimeAsync(10)
expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled()
expect(mocks.pathExists).not.toHaveBeenCalled()
expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBe(true)
} finally {
detach()
}
})
it('caps concurrent verification reads and drains the queue without dropping tabs', async () => {
// Why: a restored session with many dirty tabs must not fire one disk
// read per tab at once — on SSH/remote runtimes that competes with

View File

@ -28,6 +28,15 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
let activeVerifyReads = 0
let disposed = false
const getFileConnectionId = (file: OpenFile): string | undefined => {
const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
const externalSshTargetId = file.externalSshTargetId?.trim()
if (externalSshTargetId && connectionId !== externalSshTargetId) {
throw new Error('External SSH file owner changed')
}
return connectionId
}
// Only local/SSH paths can be probed: for runtime-owned files window.api.fs would stat the client path and misreport it as gone.
const probeFileMissing = async (file: OpenFile): Promise<boolean> => {
const settings = settingsForRuntimeOwner(store.getState().settings, file.runtimeEnvironmentId)
@ -37,7 +46,7 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
try {
const exists = await globalThis.window?.api?.fs?.pathExists?.({
filePath: file.filePath,
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
connectionId: getFileConnectionId(file)
})
return exists === false
} catch {
@ -56,7 +65,8 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
connectionId: getFileConnectionId(file),
expectedExternalSshTargetId: file.externalSshTargetId
})
if (disposed) {
return

View File

@ -44,6 +44,7 @@ export type EditorConfigParams = {
filePath: string
worktreeId: string
worktreeRoot: string | null
externalSshTargetId?: string
runtimeEnvironmentId?: string | null
isMac: boolean
richMarkdownSpellcheckEnabled: boolean
@ -97,6 +98,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
filePath,
worktreeId,
worktreeRoot,
externalSshTargetId,
runtimeEnvironmentId,
isMac,
richMarkdownSpellcheckEnabled,
@ -223,6 +225,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
nextEditor,
createRichMarkdownImageResolverContext({
filePath,
externalSshTargetId,
runtimeEnvironmentId,
settings,
worktreeId,

View File

@ -25,12 +25,14 @@ type RichMarkdownImageStorage = {
export function createRichMarkdownImageResolverContext({
filePath,
externalSshTargetId,
runtimeEnvironmentId,
settings,
worktreeId,
worktreeRoot
}: {
filePath: string
externalSshTargetId?: string
runtimeEnvironmentId?: string | null
settings: RichMarkdownImageResolverSettings
worktreeId: string
@ -43,7 +45,8 @@ export function createRichMarkdownImageResolverContext({
settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId),
worktreeId,
worktreePath: worktreeRoot,
connectionId: getConnectionId(worktreeId)
connectionId: getConnectionId(worktreeId),
expectedExternalSshTargetId: externalSshTargetId
}
: undefined
}
@ -83,6 +86,7 @@ function getRichMarkdownImageContextSignature(context: RichMarkdownImageResolver
context.filePath,
context.runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client',
context.runtimeContext?.connectionId ?? 'local',
context.runtimeContext?.expectedExternalSshTargetId ?? '',
context.runtimeContext?.worktreeId ?? 'unknown-worktree',
context.runtimeContext?.worktreePath ?? ''
].join('\0')

View File

@ -190,6 +190,76 @@ describe('useEditorPanelContentState', () => {
)
})
it('loads an external SSH-host image when the tab is pinned to that target', async () => {
const activeFile = createOpenFile({
id: '/tmp/ssh-preview.png',
filePath: '/tmp/ssh-preview.png',
relativePath: '/tmp/ssh-preview.png',
worktreeId: 'repo-ssh::/home/user/project',
externalSshTargetId: 'ssh-1'
} as never)
mocks.getConnectionIdForFile.mockReturnValue('ssh-1')
mocks.readRuntimeFileContent.mockResolvedValue({
content: 'base64-image',
isBinary: true,
isImage: true,
mimeType: 'image/png'
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
})
await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.isImage).toBe(true))
expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/tmp/ssh-preview.png',
relativePath: '/tmp/ssh-preview.png',
worktreeId: 'repo-ssh::/home/user/project',
connectionId: 'ssh-1',
expectedExternalSshTargetId: 'ssh-1'
})
)
})
it('rejects an external SSH-host tab after its target owner changes', async () => {
const activeFile = createOpenFile({
id: '/tmp/ssh-preview.png',
filePath: '/tmp/ssh-preview.png',
relativePath: '/tmp/ssh-preview.png',
worktreeId: 'repo-ssh::/home/user/project',
externalSshTargetId: 'ssh-original'
} as never)
mocks.getConnectionIdForFile.mockReturnValue('ssh-replacement')
mocks.readRuntimeFileContent.mockRejectedValue(
new Error('External SSH files are not available after the workspace host changes.')
)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
})
await vi.waitFor(() =>
expect(latestFileContents[activeFile.id]?.loadError).toBe(
'External SSH files are not available after the workspace host changes.'
)
)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith(
expect.objectContaining({
connectionId: 'ssh-replacement',
expectedExternalSshTargetId: 'ssh-original'
})
)
})
it('loads folder workspace branch diffs through the path-specific SSH connection', async () => {
const activeFile = createOpenFile({
id: 'branch-diff',

View File

@ -149,15 +149,21 @@ export function useEditorPanelContentState({
throw new Error(WORKTREE_OWNER_NOT_READY_ERROR)
}
if (restoredOpenFile?.filePath === filePath && restoredOpenFile.relativePath === filePath) {
if (readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId) {
const externalSshTargetId = restoredOpenFile.externalSshTargetId?.trim()
if (
!externalSshTargetId &&
(readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId)
) {
// Why: restored external-file tabs contain client-local absolute
// paths. Remote runtime and SSH workspaces cannot read those paths
// without an explicit upload/import flow.
throw new Error('External local files are not available for remote workspaces.')
}
// Why: restored external-file tabs need their main-process path grant
// refreshed because that authorization is only held in memory.
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
if (!externalSshTargetId) {
// Why: restored external-file tabs need their main-process path grant
// refreshed because that authorization is only held in memory.
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
}
}
const readScope = getRuntimeFileReadScope(readSettings, connectionId)
const key = inFlightReadKey(readScope, filePath)
@ -174,6 +180,7 @@ export function useEditorPanelContentState({
relativePath: restoredOpenFile?.relativePath ?? relativePath,
worktreeId,
connectionId,
expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId,
includeLocalLogMetadata:
restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true
}) as Promise<FileContent>

View File

@ -223,6 +223,23 @@ describe('loadLocalImageSrc', () => {
expect(readFile).toHaveBeenCalledTimes(2)
})
it('does not load an external SSH image through a replacement target', async () => {
const readFile = vi.fn().mockResolvedValue(binaryPreview())
setReadFile(readFile)
await expect(
loadLocalImageSrc('diagram.png', '/tmp/readme.md', null, {
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'wt-1',
worktreePath: '/repo',
connectionId: 'ssh-2',
expectedExternalSshTargetId: 'ssh-1'
})
).resolves.toBeNull()
expect(readFile).not.toHaveBeenCalled()
})
it('does not update mounted hook state after unmount', async () => {
const read = deferred<PreviewResult>()
const readFile = vi.fn().mockReturnValue(read.promise)

View File

@ -22,6 +22,7 @@ export function getLocalImageCacheKey(
return [
runtimeEnvironmentId,
runtimeContext?.connectionId ?? connectionId ?? 'local',
runtimeContext?.expectedExternalSshTargetId ?? '',
runtimeContext?.worktreeId ?? 'unknown-worktree',
absolutePath
].join('\0')

View File

@ -20,6 +20,7 @@ type RichMarkdownProgrammaticSyncOptions = {
editor: Editor | null
fileId: string
filePath: string
externalSshTargetId?: string
isApplyingProgrammaticUpdateRef: MutableRefObject<boolean>
lastCommittedMarkdownRef: MutableRefObject<string>
originalSourceRef: MutableRefObject<string>
@ -46,6 +47,7 @@ export function useRichMarkdownProgrammaticSync({
editor,
fileId,
filePath,
externalSshTargetId,
isApplyingProgrammaticUpdateRef,
lastCommittedMarkdownRef,
originalSourceRef,
@ -68,6 +70,7 @@ export function useRichMarkdownProgrammaticSync({
editor,
createRichMarkdownImageResolverContext({
filePath,
externalSshTargetId,
runtimeEnvironmentId,
settings,
worktreeId,
@ -79,6 +82,7 @@ export function useRichMarkdownProgrammaticSync({
}
}, [
editor,
externalSshTargetId,
filePath,
isApplyingProgrammaticUpdateRef,
runtimeEnvironmentId,

View File

@ -7,6 +7,7 @@ import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-htm
type ReconcileRoundTripParams = {
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
filePath: string
externalSshTargetId?: string
runtimeEnvironmentId?: string | null
worktreeId: string
worktreeRoot: string | null
@ -21,6 +22,7 @@ type ReconcileRoundTripParams = {
export function useRichMarkdownReconcileRoundTrip({
htmlSuperscriptLinkContext,
filePath,
externalSshTargetId,
runtimeEnvironmentId,
worktreeId,
worktreeRoot
@ -32,6 +34,7 @@ export function useRichMarkdownReconcileRoundTrip({
htmlSuperscriptLinkContext,
imageResolverContext: createRichMarkdownImageResolverContext({
filePath,
externalSshTargetId,
runtimeEnvironmentId,
settings,
worktreeId,

View File

@ -191,7 +191,14 @@ export function openDetectedFilePath(
worktreeId: worktreeId || '',
language,
mode: 'edit',
runtimeEnvironmentId
runtimeEnvironmentId,
// Why: absolute SSH paths outside the worktree otherwise look identical
// to client-local external files when the editor reloads or restores.
...(relativePath === filePath &&
!fileContext.settings?.activeRuntimeEnvironmentId?.trim() &&
fileContext.connectionId
? { externalSshTargetId: fileContext.connectionId }
: {})
},
{ forceContentReload: true }
)

View File

@ -933,6 +933,54 @@ describe('handleOscLink', () => {
)
})
it('pins SSH links outside the worktree to their target host', async () => {
setPlatform('Macintosh')
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
openDetectedFilePath('/tmp/ssh-preview.png', null, null, {
worktreeId: 'wt-1',
worktreePath: '/home/me/repo'
})
await flushAsyncWork()
expect(authorizeExternalPathMock).not.toHaveBeenCalled()
expect(statMock).toHaveBeenCalledWith({
filePath: '/tmp/ssh-preview.png',
connectionId: 'ssh-1'
})
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/tmp/ssh-preview.png',
relativePath: '/tmp/ssh-preview.png',
externalSshTargetId: 'ssh-1'
}),
{ forceContentReload: true }
)
})
it('does not pin runtime-owned links to the worktree SSH target', async () => {
setPlatform('Windows')
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
runtimeEnvironmentCallMock.mockResolvedValueOnce({
id: 'rpc-1',
ok: true,
result: { size: 1, isDirectory: false, mtime: 1 },
_meta: { runtimeId: 'remote-runtime' }
})
openDetectedFilePath('//wsl.localhost/ubuntu/home/Alice/repo/src/main.ts', null, null, {
worktreeId: 'wt-1',
worktreePath: '//wsl$/Ubuntu/home/Alice/repo',
runtimeEnvironmentId: 'env-1'
})
await flushAsyncWork()
expect(openFileMock).toHaveBeenCalledWith(
expect.not.objectContaining({ externalSshTargetId: expect.anything() }),
{ forceContentReload: true }
)
})
it('does not open SSH html file links as client-local file browser tabs', async () => {
setPlatform('Macintosh')
vi.mocked(getConnectionId).mockReturnValue('ssh-1')

View File

@ -617,8 +617,14 @@ function readFileForEchoVerification(args: {
relativePath: string
worktreeId: string | null | undefined
connectionId: string | undefined
expectedExternalSshTargetId?: string
}): ReturnType<typeof readRuntimeFileContent> {
const key = `${args.runtimeEnvironmentId ?? ''}::${args.connectionId ?? ''}::${args.filePath}`
const key = [
args.runtimeEnvironmentId ?? '',
args.connectionId ?? '',
args.expectedExternalSshTargetId ?? '',
args.filePath
].join('::')
let pending = inFlightEchoVerificationReads.get(key)
if (!pending) {
pending = readRuntimeFileContent({
@ -628,7 +634,8 @@ function readFileForEchoVerification(args: {
filePath: args.filePath,
relativePath: args.relativePath,
worktreeId: args.worktreeId ?? undefined,
connectionId: args.connectionId
connectionId: args.connectionId,
expectedExternalSshTargetId: args.expectedExternalSshTargetId
})
inFlightEchoVerificationReads.set(key, pending)
const release = (): void => {
@ -791,7 +798,8 @@ function scheduleSelfMoveEchoVerification(
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId: target.connectionId
connectionId: target.connectionId,
expectedExternalSshTargetId: file.externalSshTargetId
})
.then((result) => {
const diskSignature = result.isBinary ? null : getDiskBaselineSignature(result.content)
@ -826,7 +834,8 @@ function scheduleSelfWriteAwareExternalReload(
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId: target.connectionId
connectionId: target.connectionId,
expectedExternalSshTargetId: file.externalSshTargetId
})
.then((result) => {
if (

View File

@ -168,6 +168,35 @@ describe('editor file operation owner', () => {
).toThrow('Reopen the file')
})
it('rejects a restored external SSH file after its target changes', () => {
useAppStore.setState({
repos: [{ id: 'repo', connectionId: 'ssh-replacement' } as never],
worktreesByRepo: {
repo: [{ id: worktreeId, repoId: 'repo', path: '/remote/repo' } as never]
},
sshConnectionStates: new Map([
[
'ssh-replacement',
{
targetId: 'ssh-replacement',
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 1
}
]
])
})
expect(() =>
getEditorFileOperationContext(
useAppStore.getState(),
{ worktreeId, externalSshTargetId: 'ssh-original' },
'/remote/repo'
)
).toThrow('Reopen the file')
})
describe('folder workspaces', () => {
const folderWorkspaceId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const folderKey = `folder:${folderWorkspaceId}`

View File

@ -133,6 +133,7 @@ export function getEditorFileOperationContext(
file: {
worktreeId: string
runtimeEnvironmentId?: string | null
externalSshTargetId?: string
operationProvenance?: EditorFileOperationProvenance
},
worktreePath: string | null
@ -168,6 +169,15 @@ export function getEditorFileOperationContext(
if (!host) {
throw new Error(OWNER_CHANGED_MESSAGE)
}
const externalSshTargetId = file.externalSshTargetId?.trim()
if (
externalSshTargetId &&
(host.kind !== 'ssh' ||
route.runtimeEnvironmentId !== null ||
host.targetId !== externalSshTargetId)
) {
throw new Error(OWNER_CHANGED_MESSAGE)
}
if (host?.kind === 'ssh' && provenance.expectedSshConnectionGeneration === undefined) {
// Why: an old/partial SSH publication may be readable but cannot safely authorize mutations.
throw new Error(OWNER_CHANGED_MESSAGE)

View File

@ -79,6 +79,29 @@ describe('workspace session editor drafts', () => {
])
})
it('persists the SSH target that owns an external host file', () => {
const payload = buildWorkspaceSessionPayload(
createSnapshot({
openFiles: [
{
id: '/tmp/ssh-preview.png',
filePath: '/tmp/ssh-preview.png',
relativePath: '/tmp/ssh-preview.png',
worktreeId: 'wt-1',
language: 'png',
mode: 'edit',
isDirty: false,
externalSshTargetId: 'ssh-1'
} as never
]
})
)
expect(payload.openFilesByWorktree?.['wt-1']?.[0]).toEqual(
expect.objectContaining({ externalSshTargetId: 'ssh-1' })
)
})
it('persists the disk baseline signature only alongside a dirty draft', () => {
const payload = buildWorkspaceSessionPayload(
createSnapshot({

View File

@ -124,6 +124,7 @@ export function buildEditorSessionData(
language: f.language,
isPreview: f.isPreview || undefined,
runtimeEnvironmentId: f.runtimeEnvironmentId,
externalSshTargetId: f.externalSshTargetId,
// Why: persist readOnly only when true; absence is the writable default on restore.
...(f.readOnly === true ? { readOnly: true } : {}),
...(f.readOnly === true && f.liveTail === true ? { liveTail: true } : {}),

View File

@ -246,7 +246,8 @@ async function readFileContent(file: OpenFile): Promise<string> {
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId
connectionId,
expectedExternalSshTargetId: file.externalSshTargetId
})) as FileContent
if (result.isBinary) {
throw new Error('binary_file')

View File

@ -159,6 +159,59 @@ describe('runtime file client', () => {
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('reads an external SSH file only from its owning target', async () => {
const sshResult: RuntimeReadableFileContent = { content: 'remote', isBinary: false }
fsReadFile.mockResolvedValue(sshResult)
await expect(
readRuntimeFileContent({
settings: { activeRuntimeEnvironmentId: null },
filePath: '/tmp/external.md',
relativePath: '/tmp/external.md',
worktreeId: 'wt-1',
connectionId: 'ssh-1',
expectedExternalSshTargetId: 'ssh-1'
})
).resolves.toBe(sshResult)
expect(fsReadFile).toHaveBeenCalledWith({
filePath: '/tmp/external.md',
connectionId: 'ssh-1',
includeLocalLogMetadata: undefined
})
})
it('rejects an external SSH file read after the target changes', async () => {
await expect(
readRuntimeFileContent({
settings: { activeRuntimeEnvironmentId: null },
filePath: '/tmp/external.md',
relativePath: '/tmp/external.md',
worktreeId: 'wt-1',
connectionId: 'ssh-2',
expectedExternalSshTargetId: 'ssh-1'
})
).rejects.toThrow('External SSH files are not available after the workspace host changes.')
expect(fsReadFile).not.toHaveBeenCalled()
})
it('rejects an external SSH file read through a runtime environment', async () => {
await expect(
readRuntimeFileContent({
settings: { activeRuntimeEnvironmentId: 'env-1' },
filePath: '/tmp/external.md',
relativePath: '/tmp/external.md',
worktreeId: 'wt-1',
connectionId: 'ssh-1',
expectedExternalSshTargetId: 'ssh-1'
})
).rejects.toThrow('External SSH files are not available after the workspace host changes.')
expect(fsReadFile).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('binds direct SSH mutations to the captured target and generation', async () => {
const context = {
settings: { activeRuntimeEnvironmentId: null },
@ -547,6 +600,23 @@ describe('runtime file client', () => {
})
})
it('rejects an external SSH image preview after the target changes', async () => {
await expect(
readRuntimeFilePreview(
{
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'wt-1',
worktreePath: '/remote/repo',
connectionId: 'ssh-2',
expectedExternalSshTargetId: 'ssh-1'
},
'/tmp/logo.png'
)
).rejects.toThrow('External SSH files are not available after the workspace host changes.')
expect(fsReadFile).not.toHaveBeenCalled()
})
it('does not fall back to client-local preview reads for remote-owned files outside the worktree', async () => {
await expect(
readRuntimeFilePreview(

View File

@ -52,6 +52,7 @@ export type RuntimeFileReadArgs = {
relativePath?: string
worktreeId?: string
connectionId?: string
expectedExternalSshTargetId?: string
includeLocalLogMetadata?: boolean
}
@ -63,6 +64,21 @@ export type RuntimeFileOperationArgs = {
expectedExecutionHostId?: 'local' | `ssh:${string}`
expectedSshTargetId?: string
expectedSshConnectionGeneration?: number
expectedExternalSshTargetId?: string
}
function assertExternalSshReadOwnership(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
connectionId: string | undefined,
expectedExternalSshTargetId: string | undefined
): void {
const expectedTargetId = expectedExternalSshTargetId?.trim()
if (
expectedTargetId &&
(getActiveRuntimeTarget(settings).kind === 'environment' || connectionId !== expectedTargetId)
) {
throw new Error('External SSH files are not available after the workspace host changes.')
}
}
function withSshMutationExpectation<T extends object>(
@ -227,8 +243,10 @@ export async function readRuntimeFileContent({
relativePath,
worktreeId,
connectionId,
expectedExternalSshTargetId,
includeLocalLogMetadata
}: RuntimeFileReadArgs): Promise<RuntimeReadableFileContent> {
assertExternalSshReadOwnership(settings, connectionId, expectedExternalSshTargetId)
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
return window.api.fs.readFile({ filePath, connectionId, includeLocalLogMetadata })
@ -275,6 +293,11 @@ export async function readRuntimeFilePreview(
context: RuntimeFileOperationArgs,
filePath: string
): Promise<RuntimeFilePreviewResult> {
assertExternalSshReadOwnership(
context.settings,
context.connectionId,
context.expectedExternalSshTargetId
)
const remoteArgs = getRemoteFileArgs(context, filePath)
if (!remoteArgs) {
if (hasRemoteRuntimeOwner(context)) {
@ -295,6 +318,11 @@ export async function downloadRuntimeFile(
filePath: string,
suggestedName: string
): Promise<RuntimeFileDownloadResult> {
assertExternalSshReadOwnership(
context.settings,
context.connectionId,
context.expectedExternalSshTargetId
)
const remoteArgs = getRemoteFileArgs(context, filePath)
if (!remoteArgs) {
if (hasRemoteRuntimeOwner(context)) {

View File

@ -653,6 +653,62 @@ describe('createEditorSlice openDiff', () => {
expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBe(2)
})
it('rebinds an existing external tab when it is reopened from a new SSH host', () => {
const store = createEditorStore()
const file = {
filePath: '/tmp/ssh-preview.png',
relativePath: '/tmp/ssh-preview.png',
worktreeId: 'wt-1',
language: 'png',
mode: 'edit' as const
}
store.setState({
repos: [{ id: 'repo-1', path: '/repo', connectionId: 'ssh-1' }],
sshConnectionStates: new Map([
[
'ssh-1',
{
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 1
}
]
])
} as never)
store.getState().openFile({ ...file, externalSshTargetId: 'ssh-1' })
store.setState({
repos: [{ id: 'repo-1', path: '/repo', connectionId: 'ssh-2' }],
sshConnectionStates: new Map([
[
'ssh-2',
{
targetId: 'ssh-2',
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 2
}
]
])
} as never)
store.getState().openFile({ ...file, externalSshTargetId: 'ssh-2' })
expect(store.getState().openFiles).toHaveLength(1)
expect(store.getState().openFiles[0]?.externalSshTargetId).toBe('ssh-2')
expect(store.getState().openFiles[0]?.operationProvenance).toEqual(
expect.objectContaining({
generation: expect.objectContaining({
route: { executionHostId: 'ssh:ssh-2', runtimeEnvironmentId: null }
}),
expectedSshConnectionGeneration: 2
})
)
})
it('does not bump fileContentReloadNonce when a dirty file is re-opened', () => {
const store = createEditorStore()
@ -1901,6 +1957,36 @@ describe('createEditorSlice markdown table of contents visibility', () => {
})
describe('createEditorSlice openMarkdownPreview', () => {
it('keeps external SSH ownership after the source edit tab closes', () => {
const store = createEditorStore()
store.getState().openFile({
filePath: '/tmp/notes.md',
relativePath: '/tmp/notes.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit',
externalSshTargetId: 'ssh-1'
})
store.getState().openMarkdownPreview(
{
filePath: '/tmp/notes.md',
relativePath: '/tmp/notes.md',
worktreeId: 'wt-1',
language: 'markdown'
},
{ sourceFileId: '/tmp/notes.md' }
)
store.getState().closeFile('/tmp/notes.md')
expect(store.getState().openFiles).toEqual([
expect.objectContaining({
id: 'markdown-preview::/tmp/notes.md',
externalSshTargetId: 'ssh-1'
})
])
})
it('opens markdown preview as a separate read-only tab', () => {
const store = createEditorStore()
@ -4941,4 +5027,30 @@ describe('read-only editor tabs (AI Vault View Log)', () => {
expect(restored?.pendingDiskBaselineVerification).toBeUndefined()
expect(store.getState().editorDrafts[LOG_PATH]).toBeUndefined()
})
it('restores the SSH target that owns an external host file', () => {
const store = createEditorStore()
store.setState({
worktreesByRepo: { 'repo-1': [{ id: 'wt-1' }] },
folderWorkspaces: []
} as never)
store.getState().hydrateEditorSession({
openFilesByWorktree: {
'wt-1': [
{
filePath: '/tmp/ssh-preview.png',
relativePath: '/tmp/ssh-preview.png',
worktreeId: 'wt-1',
language: 'png',
externalSshTargetId: 'ssh-1'
}
]
}
} as never)
expect(store.getState().openFiles[0]).toEqual(
expect.objectContaining({ externalSshTargetId: 'ssh-1' })
)
})
})

View File

@ -215,6 +215,8 @@ export type OpenFile = {
isDirty: boolean
// Why: remote untitled cleanup must target the creating environment even if the user later switches runtime.
runtimeEnvironmentId?: string | null
/** SSH target that owns an absolute path outside the worktree. */
externalSshTargetId?: string
/** Host provenance captured when the tab opened; mutations reject replacement owners. */
operationProvenance?: EditorFileOperationProvenance
/** Why: preview tabs mirror a source file's live draft; storing its ID lets the preview follow unsaved edits without becoming editable. */
@ -467,7 +469,12 @@ export type EditorSlice = {
openMarkdownPreview: (
file: Pick<
OpenFile,
'filePath' | 'relativePath' | 'worktreeId' | 'language' | 'runtimeEnvironmentId'
| 'filePath'
| 'relativePath'
| 'worktreeId'
| 'language'
| 'runtimeEnvironmentId'
| 'externalSshTargetId'
>,
options?: { anchor?: string | null; targetGroupId?: string; sourceFileId?: string }
) => void
@ -1668,6 +1675,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
if (existing) {
// If opening as non-preview, also pin the existing tab
const updatedPreview = isPreview ? existing.isPreview : false
const nextExternalSshTargetId = file.externalSshTargetId ?? existing.externalSshTargetId
const refreshExternalSshProvenance = file.externalSshTargetId !== undefined
const fileContentReloadNonce = shouldRequestExistingFileContentReload(
existing,
file.mode,
@ -1689,6 +1698,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
existing.relativePath !== file.relativePath ||
existing.worktreeId !== file.worktreeId ||
existing.runtimeEnvironmentId !== runtimeEnvironmentId ||
existing.externalSshTargetId !== nextExternalSshTargetId ||
refreshExternalSshProvenance ||
existing.fileContentReloadNonce !== fileContentReloadNonce
if (!needsExistingUpdate) {
return { ...activeResult, ...focusRequestUpdate }
@ -1703,6 +1714,10 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
worktreeId: file.worktreeId,
language: file.language,
runtimeEnvironmentId,
externalSshTargetId: nextExternalSshTargetId,
operationProvenance: refreshExternalSshProvenance
? operationProvenance
: f.operationProvenance,
mode: file.mode,
diffSource: file.diffSource,
branchCompare: file.branchCompare,
@ -1915,6 +1930,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
['edit']
)
const id = `markdown-preview::${sourceFileId}`
const externalSshTargetId =
file.externalSshTargetId ??
initialState.openFiles.find((openFile) => openFile.id === sourceFileId)?.externalSshTargetId
const anchor = options?.anchor || undefined
set((s) => {
const existing = s.openFiles.find((openFile) => openFile.id === id)
@ -1927,6 +1945,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
existing.relativePath !== file.relativePath ||
existing.filePath !== file.filePath ||
existing.language !== file.language ||
existing.externalSshTargetId !== externalSshTargetId ||
existing.markdownPreviewSourceFileId !== sourceFileId ||
existing.markdownPreviewAnchor !== anchor ||
existing.mode !== 'markdown-preview'
@ -1941,6 +1960,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
worktreeId: file.worktreeId,
language: file.language,
runtimeEnvironmentId,
externalSshTargetId,
markdownPreviewSourceFileId: sourceFileId,
markdownPreviewAnchor: anchor,
mode: 'markdown-preview' as const
@ -1960,6 +1980,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
language: file.language,
isDirty: false,
runtimeEnvironmentId,
externalSshTargetId,
markdownPreviewSourceFileId: sourceFileId,
markdownPreviewAnchor: anchor,
mode: 'markdown-preview'
@ -4504,6 +4525,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
isDirty: !isReadOnly && pf.dirtyDraftContent !== undefined,
isPreview: pf.isPreview,
runtimeEnvironmentId: pf.runtimeEnvironmentId,
externalSshTargetId: pf.externalSshTargetId,
...(isReadOnly ? { readOnly: true } : {}),
...(isReadOnly && pf.liveTail === true ? { liveTail: true } : {}),
lastKnownDiskSignature: isReadOnly ? undefined : pf.lastKnownDiskSignature,

View File

@ -1066,6 +1066,8 @@ export type PersistedOpenFile = {
language: string
isPreview?: boolean
runtimeEnvironmentId?: string | null
/** SSH target that owns an absolute path outside the worktree. */
externalSshTargetId?: string
/** Unsaved editor buffer captured for hot exit; presence restores the tab dirty. */
dirtyDraftContent?: string
/** Signature of the disk content the dirty draft is based on; lets restore

View File

@ -14,6 +14,55 @@ describe('parseWorkspaceSession', () => {
expect(result.ok).toBe(true)
})
it('preserves external SSH file ownership across session parsing', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: 'wt',
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
wt: [
{
filePath: '/tmp/external.png',
relativePath: '/tmp/external.png',
worktreeId: 'wt',
language: 'png',
externalSshTargetId: 'ssh-1'
}
]
}
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.openFilesByWorktree?.wt?.[0]?.externalSshTargetId).toBe('ssh-1')
}
})
it('rejects blank external SSH file ownership', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: 'wt',
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
wt: [
{
filePath: '/tmp/external.png',
relativePath: '/tmp/external.png',
worktreeId: 'wt',
language: 'png',
externalSshTargetId: ' '
}
]
}
})
expect(result.ok).toBe(false)
})
it('accepts a fully populated session with optional fields', () => {
const result = parseWorkspaceSession({
activeRepoId: 'repo1',

View File

@ -161,6 +161,7 @@ const persistedOpenFileSchema = z.object({
language: z.string(),
isPreview: z.boolean().optional(),
runtimeEnvironmentId: z.string().nullable().optional(),
externalSshTargetId: z.string().trim().min(1).optional(),
dirtyDraftContent: z.string().optional(),
lastKnownDiskSignature: z.string().optional(),
readOnly: z.boolean().optional(),

View File

@ -0,0 +1,197 @@
import { createHash } from 'node:crypto'
import type { Page } from '@stablyai/playwright-test'
import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
import {
cleanupDockerSshRelayTarget,
DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
execDockerSshRelayTargetCommand,
shellQuote,
startDockerSshRelayTarget,
type DockerSshRelayTarget
} from './helpers/docker-ssh-relay-target'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
const REMOTE_IMAGE_PATH = '/tmp/orca-ssh-external-preview.png'
const IMAGE_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVR4AWN8z8DwnwEJMDGgAcICAO2mBAXmO4drAAAAAElFTkSuQmCC'
type LinkProbe = { col: number; row: number; tabId: string }
async function findTerminalLink(page: Page, text: string): Promise<LinkProbe> {
return page.evaluate((text) => {
const state = window.__store?.getState()
const tabId = state?.activeTabId ?? null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!tabId || !pane) {
throw new Error('Active terminal pane is unavailable')
}
const terminal = pane.terminal
for (let row = 0; row < terminal.rows; row += 1) {
const line = terminal.buffer.active.getLine(terminal.buffer.active.viewportY + row)
const col = line?.translateToString(true).indexOf(text) ?? -1
if (col >= 0) {
return { col: col + Math.floor(text.length / 2), row, tabId }
}
}
throw new Error('External image path is not visible in the terminal')
}, text)
}
async function activateTerminalLink(page: Page, probe: LinkProbe, text: string): Promise<void> {
await expect
.poll(
async () => {
await page.evaluate(({ col, row, tabId }) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const screen = pane?.terminal.element?.querySelector<HTMLElement>('.xterm-screen')
if (!pane || !screen) {
throw new Error('Active terminal screen is unavailable')
}
const rect = screen.getBoundingClientRect()
screen.dispatchEvent(
new MouseEvent('mousemove', {
bubbles: true,
cancelable: true,
clientX: rect.left + (col + 0.5) * (rect.width / pane.terminal.cols),
clientY: rect.top + (row + 0.5) * (rect.height / pane.terminal.rows)
})
)
}, probe)
return page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const core = pane?.terminal as unknown as
| { _core?: { linkifier?: { currentLink?: { link?: { text?: string } } } } }
| undefined
return core?._core?.linkifier?.currentLink?.link?.text ?? null
}, probe.tabId)
},
{ timeout: 10_000, message: 'External SSH image path did not become clickable' }
)
.toContain(text)
await page.evaluate(({ col, row, tabId }) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const screen = pane?.terminal.element?.querySelector<HTMLElement>('.xterm-screen')
if (!pane || !screen) {
throw new Error('Active terminal screen is unavailable')
}
const rect = screen.getBoundingClientRect()
const mouse = {
bubbles: true,
cancelable: true,
button: 0,
clientX: rect.left + (col + 0.5) * (rect.width / pane.terminal.cols),
clientY: rect.top + (row + 0.5) * (rect.height / pane.terminal.rows),
metaKey: navigator.userAgent.includes('Mac'),
ctrlKey: !navigator.userAgent.includes('Mac')
}
screen.dispatchEvent(new MouseEvent('mousedown', { ...mouse, buttons: 1 }))
screen.dispatchEvent(new MouseEvent('mouseup', mouse))
}, probe)
}
test.describe('SSH external image preview', () => {
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.')
test.skip(process.platform === 'win32', 'The disposable SSH host uses POSIX tooling.')
test('opens an image outside the worktree from a terminal link', async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
test.slow()
let target: DockerSshRelayTarget | null = null
let cleanupDeferred = false
try {
target = startDockerSshRelayTarget(testInfo)
registerPostElectronShutdownCleanup(async () => cleanupDockerSshRelayTarget(target))
cleanupDeferred = true
execDockerSshRelayTargetCommand(
target,
`printf '%s' ${shellQuote(IMAGE_BASE64)} | base64 -d > ${shellQuote(REMOTE_IMAGE_PATH)}`
)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const remote = await connectDockerSshRelayTarget(orcaPage, target, {
remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH
})
await ensureTerminalVisible(orcaPage, 45_000)
await waitForActiveTerminalManager(orcaPage, 60_000)
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
const readyMarker = `SSH_PREVIEW_READY_${Date.now()}`
const encodedReadyMarker = Buffer.from(readyMarker).toString('base64')
await sendToTerminal(
orcaPage,
ptyId,
`printf '%s' ${shellQuote(encodedReadyMarker)} | base64 -d; printf '\\n'\r`
)
await expect
.poll(() => getTerminalContent(orcaPage, 30_000), {
timeout: 15_000,
message: 'SSH terminal did not execute the readiness marker'
})
.toContain(readyMarker)
await sendToTerminal(orcaPage, ptyId, `printf '%s\\n' ${shellQuote(REMOTE_IMAGE_PATH)}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 30_000), {
timeout: 15_000,
message: 'External image path did not reach the SSH terminal'
})
.toContain(REMOTE_IMAGE_PATH)
const probe = await findTerminalLink(orcaPage, REMOTE_IMAGE_PATH)
await activateTerminalLink(orcaPage, probe, REMOTE_IMAGE_PATH)
const preview = orcaPage.locator(`img[alt="${REMOTE_IMAGE_PATH.split('/').at(-1)}"]`)
await expect(preview).toBeVisible({ timeout: 30_000 })
expect(await preview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(
2
)
expect(await preview.getAttribute('src')).toBe(`data:image/png;base64,${IMAGE_BASE64}`)
await expect(orcaPage.getByText('Unable to load file', { exact: true })).toHaveCount(0)
const state = await orcaPage.evaluate((filePath) => {
const file = window.__store?.getState().openFiles.find((item) => item.filePath === filePath)
return file
? {
externalSshTargetId: file.externalSshTargetId,
relativePath: file.relativePath
}
: null
}, REMOTE_IMAGE_PATH)
expect(state).toEqual({
externalSshTargetId: remote.targetId,
relativePath: REMOTE_IMAGE_PATH
})
const remoteHash = execDockerSshRelayTargetCommand(
target,
`sha256sum ${shellQuote(REMOTE_IMAGE_PATH)} | cut -d' ' -f1`
)
expect(remoteHash).toBe(
createHash('sha256').update(Buffer.from(IMAGE_BASE64, 'base64')).digest('hex')
)
await testInfo.attach('ssh-external-image-preview', {
body: await orcaPage.screenshot(),
contentType: 'image/png'
})
} finally {
if (!cleanupDeferred) {
cleanupDockerSshRelayTarget(target)
}
}
})
})