diff --git a/mobile/src/files/mobile-file-mutation-ownership.test.ts b/mobile/src/files/mobile-file-mutation-ownership.test.ts index dc81a2a8391..b916471fb22 100644 --- a/mobile/src/files/mobile-file-mutation-ownership.test.ts +++ b/mobile/src/files/mobile-file-mutation-ownership.test.ts @@ -104,6 +104,19 @@ describe('mobile file mutation ownership', () => { }) }) + it('preserves runtime-owned SSH routing and its connection fence', () => { + expect( + buildMobileFileMutationOwnership( + 'ssh:runtime-ssh-environment-1', + sshState('runtime-ssh-environment-1', 18) + ) + ).toEqual({ + expectedExecutionHostId: 'ssh:runtime-ssh-environment-1', + expectedSshTargetId: 'runtime-ssh-environment-1', + expectedSshConnectionGeneration: 18 + }) + }) + it.each([ ['a missing SSH state', 'ssh:target-1', null], ['a reconnecting SSH state', 'ssh:target-1', sshState('target-1', 4, 'reconnecting')], diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index f92ebd6b656..c13f6310e28 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -104,6 +104,7 @@ import { setSshConnectionGeneration } from '../ssh/ssh-connection-generation' import { SEARCH_TIMEOUT_MS } from '../../shared/text-search' +import type { Worktree } from '../../shared/types' type MockRuntimeSearchChild = EventEmitter & { stdout: EventEmitter & { setEncoding: ReturnType } @@ -139,6 +140,7 @@ function mockLocalPathStats(entries: Record) { } function createRuntimeFileCommands(options?: { + hostId?: Worktree['hostId'] path?: string openFile?: ReturnType openDiff?: ReturnType @@ -156,7 +158,8 @@ function createRuntimeFileCommands(options?: { const worktree = { id: 'wt-1', repoId: 'repo-1', - path + path, + hostId: options?.hostId } const commands = new RuntimeFileCommands({ getRuntimeId: () => 'runtime-1', @@ -430,8 +433,19 @@ describe('RuntimeFileCommands', () => { expect(renameMock).not.toHaveBeenCalled() }) + it('rejects a HUB-local create when the worktree declares another runtime owner', async () => { + const { commands } = createRuntimeFileCommands({ hostId: 'runtime:environment-2' }) + + await expect( + commands.createFileExplorerFile('id:wt-1', 'unsafe.md', undefined, undefined, 'local') + ).rejects.toThrow('Workspace host changed') + + expect(resolveAuthorizedPathMock).not.toHaveBeenCalled() + expect(getSshFilesystemProvider).not.toHaveBeenCalled() + }) + it('allows runtime-local case-only rename with IPC parity guard behavior', async () => { - const { commands } = createRuntimeFileCommands() + const { commands } = createRuntimeFileCommands({ hostId: 'local' }) mockLocalPathStats({ '/repo/README.md': [10, 100], '/repo/readme.md': [10, 100] @@ -508,7 +522,7 @@ describe('RuntimeFileCommands', () => { 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) - const { commands, store } = createRuntimeFileCommands() + const { commands, store } = createRuntimeFileCommands({ hostId: 'ssh:ssh-1' }) store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1') diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index c518a4ced3d..250a9c469cd 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -87,7 +87,7 @@ import { } from './runtime-mobile-file-path-search' import { beginWatcherInstall } from '../ipc/watcher-removal-gate' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' -import { toSshExecutionHostId } from '../../shared/execution-host' +import { parseExecutionHostId, toSshExecutionHostId } from '../../shared/execution-host' const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 @@ -103,6 +103,7 @@ const RUNTIME_FILE_MUTATION_UPDATE_REQUIRED = 'Remote file changes require a newer Orca client. Update the paired client and try again.' function assertRuntimeFileMutationExpectation( + worktreeHostId: Worktree['hostId'], connectionId: string | undefined, expectedExecutionHostId: string | undefined, expectedSshTargetId: string | undefined, @@ -112,7 +113,12 @@ function assertRuntimeFileMutationExpectation( throw new Error(RUNTIME_FILE_MUTATION_UPDATE_REQUIRED) } const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local' - if (expectedExecutionHostId !== actualExecutionHostId) { + // Why: this process cannot prove runtime:* ownership; stored metadata must match its concrete I/O route. + const declaredExecutionHost = parseExecutionHostId(worktreeHostId) + if ( + (worktreeHostId !== undefined && declaredExecutionHost?.id !== actualExecutionHostId) || + expectedExecutionHostId !== actualExecutionHostId + ) { throw new Error('Workspace host changed; refresh and try again') } assertSshMutationExpectation(connectionId, expectedSshTargetId, expectedSshConnectionGeneration) @@ -1367,6 +1373,7 @@ export class RuntimeFileCommands { ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) assertRuntimeFileMutationExpectation( + target.worktree.hostId, target.connectionId, expectedExecutionHostId, expectedSshTargetId, @@ -1406,6 +1413,7 @@ export class RuntimeFileCommands { ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) assertRuntimeFileMutationExpectation( + target.worktree.hostId, target.connectionId, expectedExecutionHostId, expectedSshTargetId, @@ -1438,6 +1446,7 @@ export class RuntimeFileCommands { ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) assertRuntimeFileMutationExpectation( + target.worktree.hostId, target.connectionId, expectedExecutionHostId, expectedSshTargetId, @@ -1468,6 +1477,7 @@ export class RuntimeFileCommands { ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) assertRuntimeFileMutationExpectation( + target.worktree.hostId, target.connectionId, expectedExecutionHostId, expectedSshTargetId, @@ -1501,6 +1511,7 @@ export class RuntimeFileCommands { ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) assertRuntimeFileMutationExpectation( + target.worktree.hostId, target.connectionId, expectedExecutionHostId, expectedSshTargetId, @@ -1530,6 +1541,7 @@ export class RuntimeFileCommands { ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) assertRuntimeFileMutationExpectation( + target.worktree.hostId, target.connectionId, expectedExecutionHostId, expectedSshTargetId, @@ -1562,6 +1574,7 @@ export class RuntimeFileCommands { finalRelativePath ]) assertRuntimeFileMutationExpectation( + tempTarget.worktree.hostId, tempTarget.connectionId, expectedExecutionHostId, expectedSshTargetId, @@ -1601,6 +1614,7 @@ export class RuntimeFileCommands { newRelativePath ]) assertRuntimeFileMutationExpectation( + oldTarget.worktree.hostId, oldTarget.connectionId, expectedExecutionHostId, expectedSshTargetId, @@ -1638,6 +1652,7 @@ export class RuntimeFileCommands { [sourceRelativePath, destinationRelativePath] ) assertRuntimeFileMutationExpectation( + sourceTarget.worktree.hostId, sourceTarget.connectionId, expectedExecutionHostId, expectedSshTargetId, @@ -1677,6 +1692,7 @@ export class RuntimeFileCommands { ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) assertRuntimeFileMutationExpectation( + target.worktree.hostId, target.connectionId, expectedExecutionHostId, expectedSshTargetId, diff --git a/src/main/runtime/rpc/methods/files-create-owner-routing.test.ts b/src/main/runtime/rpc/methods/files-create-owner-routing.test.ts new file mode 100644 index 00000000000..ffc69f53837 --- /dev/null +++ b/src/main/runtime/rpc/methods/files-create-owner-routing.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { FILE_METHODS } from './files' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('files.createFile owner routing', () => { + it('preserves a runtime owner assertion through the RPC layer', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createFileExplorerFile: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.createFile', { + worktree: 'id:wt-1', + relativePath: 'unsafe.md', + expectedExecutionHostId: 'runtime:environment-2' + }) + ) + + expect(runtime.createFileExplorerFile).toHaveBeenCalledWith( + 'id:wt-1', + 'unsafe.md', + undefined, + undefined, + 'runtime:environment-2' + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) +})