diff --git a/src/cli/handlers/file.test.ts b/src/cli/handlers/file.test.ts index 4e36ddbfff2..66f55158ff5 100644 --- a/src/cli/handlers/file.test.ts +++ b/src/cli/handlers/file.test.ts @@ -45,6 +45,7 @@ vi.mock('../runtime-client', () => { }) import { main } from '../index' +import { RuntimeRpcFailureError } from '../runtime-client' import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from '../test-fixtures' describe('orca file CLI handlers', () => { @@ -87,6 +88,29 @@ describe('orca file CLI handlers', () => { expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened src/App.tsx.') }) + it('reports a missing file as a JSON failure with a nonzero exit code', async () => { + const priorExitCode = process.exitCode + queueFixtures(callMock, worktreeListFixture([buildWorktree('/tmp/repo', 'feature')])) + callMock.mockRejectedValueOnce( + new RuntimeRpcFailureError({ + id: 'req_open', + ok: false, + error: { code: 'runtime_error', message: 'File not found: docs/missing.md' }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + + await main(['file', 'open', 'docs/missing.md', '--json'], '/tmp/repo') + + expect(JSON.parse(String(vi.mocked(console.log).mock.calls[0][0]))).toMatchObject({ + ok: false, + error: { code: 'runtime_error', message: 'File not found: docs/missing.md' } + }) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('opens a staged diff for an explicit worktree without cwd inference', async () => { queueFixtures( callMock, diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index d43e48c4695..4a52e01233f 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -248,6 +248,8 @@ describe('RuntimeFileCommands', () => { it('opens text files through the renderer host (inheriting active runtime env)', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) + statMock.mockResolvedValue(mockStats(12, 3)) const result = await commands.openMobileFile('id:wt-1', 'docs/readme.md') @@ -268,6 +270,8 @@ describe('RuntimeFileCommands', () => { it('opens previewable images through the renderer host as an image tab', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) + statMock.mockResolvedValue(mockStats(12, 3)) const result = await commands.openMobileFile('id:wt-1', 'assets/logo.png') @@ -288,6 +292,8 @@ describe('RuntimeFileCommands', () => { it('leaves non-previewable binaries unavailable on mobile', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) + statMock.mockResolvedValue(mockStats(12, 3)) const result = await commands.openMobileFile('id:wt-1', 'dist/bundle.zip') @@ -300,6 +306,43 @@ describe('RuntimeFileCommands', () => { }) }) + it('rejects a missing local file before opening a renderer tab', async () => { + const openFile = vi.fn() + const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) + statMock.mockRejectedValue(enoent()) + + await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow( + 'File not found: docs/missing.md' + ) + + expect(openFile).not.toHaveBeenCalled() + }) + + it('rejects a missing SSH file before opening a renderer tab', async () => { + const openFile = vi.fn() + const stat = vi.fn().mockRejectedValue(new Error('ENOENT: no such file')) + const { commands } = createRuntimeFileCommands({ + openFile, + resolveRuntimeFileTarget: vi.fn(async () => ({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo' + }, + connectionId: 'ssh-1' + })) + }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat } as never) + + await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow( + 'File not found: docs/missing.md' + ) + + expect(stat).toHaveBeenCalledWith('/repo/docs/missing.md') + 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.ts b/src/main/runtime/orca-runtime-files.ts index acdf3616f47..09d81dfe972 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -248,10 +248,12 @@ 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') } + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + await this.assertMobileFileExists(filePath, relativePath, connectionId) // Previewable images open like text (the mobile viewer renders them via // files.readPreview); other binaries stay unavailable on mobile. const kind = isMobilePreviewableImagePath(relativePath) @@ -264,7 +266,6 @@ export class RuntimeFileCommands { if (kind === 'binary') { return { worktree: worktree.id, relativePath, kind, opened: false } } - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) // 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 @@ -275,6 +276,32 @@ export class RuntimeFileCommands { return { worktree: worktree.id, relativePath, kind, opened: true } } + private async assertMobileFileExists( + filePath: string, + relativePath: string, + connectionId?: string + ): Promise { + try { + if (connectionId) { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.stat(filePath) + return + } + await stat(await resolveAuthorizedPath(filePath, this.host.requireStore())) + } catch (error) { + if ( + isENOENT(error) || + (connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) + ) { + throw new Error(`File not found: ${relativePath}`) + } + throw error + } + } + async openMobileDiff( worktreeSelector: string, relativePath: string,