fix(mobile): fence runtime-owned file mutations

This commit is contained in:
Jinjing
2026-07-23 00:26:18 -07:00
parent 95e4506079
commit 2a1cdcff37
4 changed files with 84 additions and 5 deletions
@@ -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')],
+17 -3
View File
@@ -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<typeof vi.fn> }
@@ -139,6 +140,7 @@ function mockLocalPathStats(entries: Record<string, [number, number]>) {
}
function createRuntimeFileCommands(options?: {
hostId?: Worktree['hostId']
path?: string
openFile?: ReturnType<typeof vi.fn>
openDiff?: ReturnType<typeof vi.fn>
@@ -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')
+18 -2
View File
@@ -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,
@@ -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 } })
})
})