mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
fix(mobile): fence runtime-owned file mutations
This commit is contained in:
@@ -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')],
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user