diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index d43e48c4695..eb7723944f7 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -248,6 +248,7 @@ describe('RuntimeFileCommands', () => { it('opens text files through the renderer host (inheriting active runtime env)', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + statMock.mockResolvedValue({ isDirectory: () => false }) const result = await commands.openMobileFile('id:wt-1', 'docs/readme.md') @@ -268,6 +269,7 @@ describe('RuntimeFileCommands', () => { it('opens previewable images through the renderer host as an image tab', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + statMock.mockResolvedValue({ isDirectory: () => false }) const result = await commands.openMobileFile('id:wt-1', 'assets/logo.png') @@ -285,6 +287,43 @@ describe('RuntimeFileCommands', () => { }) }) + it('fails the open when the resolved local file does not exist', async () => { + const openFile = vi.fn() + const { commands } = createRuntimeFileCommands({ openFile }) + statMock.mockRejectedValue(enoent()) + + await expect(commands.openMobileFile('id:wt-1', 'spec/does-not-exist.md')).rejects.toThrow( + 'file_not_found' + ) + expect(openFile).not.toHaveBeenCalled() + }) + + it('fails the open when the resolved path is a directory', async () => { + const openFile = vi.fn() + const { commands } = createRuntimeFileCommands({ openFile }) + statMock.mockResolvedValue({ isDirectory: () => true }) + + await expect(commands.openMobileFile('id:wt-1', 'docs')).rejects.toThrow('file_not_found') + expect(openFile).not.toHaveBeenCalled() + }) + + it('fails the open when the remote SSH file does not exist', async () => { + const openFile = vi.fn() + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: 'ssh-1' + })) + const { commands } = createRuntimeFileCommands({ openFile, resolveRuntimeFileTarget }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn().mockRejectedValue(new Error('No such file or directory')) + } as never) + + await expect(commands.openMobileFile('id:wt-1', 'spec/does-not-exist.md')).rejects.toThrow( + 'file_not_found' + ) + expect(openFile).not.toHaveBeenCalled() + }) + it('leaves non-previewable binaries unavailable on mobile', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index acdf3616f47..6bfeabe5266 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -248,7 +248,7 @@ export class RuntimeFileCommands { worktreeSelector: string, relativePath: string ): Promise { - const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector) if (!isSafeMobileRelativePath(relativePath)) { throw new Error('invalid_relative_path') } @@ -265,6 +265,12 @@ export class RuntimeFileCommands { return { worktree: worktree.id, relativePath, kind, opened: false } } const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + // Why: a missing (or directory) path used to report opened:true / exit 0 + // and create a ghost tab that only failed at render time, misleading + // CLI-driving agents into trusting the open succeeded (#8844). + if (!(await this.resolvedOpenTargetIsFile(filePath, connectionId))) { + throw new Error('file_not_found') + } // Why: the service's internal runtimeId is not a registered runtime env selector // (those live in orca-environments.json). Passing it caused Unknown environment // errors on content load for CLI-initiated opens (via files.open from orca cli @@ -482,6 +488,29 @@ export class RuntimeFileCommands { return /\bENOENT\b|no such file|not found|does not exist/i.test(message) } + // Existence probe for files.open across local and SSH worktrees. Genuine + // not-found (or a directory) → false; transport/permission failures still + // throw so a dropped remote session doesn't read as "file missing". + private async resolvedOpenTargetIsFile( + filePath: string, + connectionId: string | undefined + ): Promise { + try { + const stats = connectionId + ? await this.statRemoteTerminalPath(filePath, connectionId) + : await stat(filePath) + return !stats.isDirectory() + } catch (error) { + if ( + isENOENT(error) || + (connectionId !== undefined && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) + ) { + return false + } + throw error + } + } + private async statRemoteTerminalPath( absolutePath: string, connectionId: string diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index bb800b54617..6d94fd3a092 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -45,6 +45,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'terminal_gone', 'no_active_terminal', 'repo_not_found', + 'file_not_found', 'timeout', 'invalid_limit' ])