From 62b9cf2feee699dee663c2ba82c97f7bda4f9c39 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:14:30 -0700 Subject: [PATCH] fix(mobile): bind markdown creation to file owner (#10083) --- .../app/h/[hostId]/session/[worktreeId].tsx | 4 +- .../mobile-file-mutation-ownership.test.ts | 116 ++++++++++++++++++ .../files/mobile-file-mutation-ownership.ts | 81 ++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 mobile/src/files/mobile-file-mutation-ownership.test.ts create mode 100644 mobile/src/files/mobile-file-mutation-ownership.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 11764a25189..da27cc167e8 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -182,6 +182,7 @@ import { useTerminalLiveInputModePreference } from '../../../../src/session/use- import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc' +import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership' import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open' import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name' import { @@ -3897,11 +3898,12 @@ export default function SessionScreen() { try { const worktree = `id:${worktreeId}` + const mutationOwnership = await captureMobileFileMutationOwnership(client, worktree) for (let attempt = 1; attempt <= 100; attempt += 1) { const relativePath = attempt === 1 ? 'untitled.md' : `untitled-${attempt}.md` const createResponse = await client.sendRequest( 'files.createFile', - { worktree, relativePath }, + { worktree, relativePath, ...mutationOwnership }, { timeoutMs: 15_000 } ) if (!createResponse.ok) { diff --git a/mobile/src/files/mobile-file-mutation-ownership.test.ts b/mobile/src/files/mobile-file-mutation-ownership.test.ts new file mode 100644 index 00000000000..f3e98dca9c6 --- /dev/null +++ b/mobile/src/files/mobile-file-mutation-ownership.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SshConnectionState } from '../../../src/shared/ssh-types' +import { + FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, + FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE +} from '../../../src/shared/protocol-version' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { + buildMobileFileMutationOwnership, + captureMobileFileMutationOwnership +} from './mobile-file-mutation-ownership' + +function success(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function clientWithResponses(responses: RpcResponse[]): { + client: Pick + sendRequest: ReturnType +} { + const sendRequest = vi.fn(async () => { + const response = responses.shift() + if (!response) { + throw new Error('Unexpected RPC request') + } + return response + }) + return { client: { sendRequest }, sendRequest } +} + +function sshState(targetId: string, connectionGeneration: number | undefined): SshConnectionState { + return { + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration + } +} + +describe('mobile file mutation ownership', () => { + it.each([undefined, 'local', 'runtime:environment-1'])( + 'binds %s worktrees to the runtime-local file host', + (hostId) => { + expect(buildMobileFileMutationOwnership(hostId)).toEqual({ + expectedExecutionHostId: 'local' + }) + } + ) + + it('binds SSH worktrees to the target and live connection generation', () => { + expect( + buildMobileFileMutationOwnership('ssh:target%20one', sshState('target one', 17)) + ).toEqual({ + expectedExecutionHostId: 'ssh:target%20one', + expectedSshTargetId: 'target one', + expectedSshConnectionGeneration: 17 + }) + }) + + it.each([ + ['a malformed owner', 'not-an-execution-host', null], + ['a missing SSH state', 'ssh:target-1', null], + ['a mismatched SSH target', 'ssh:target-1', sshState('target-2', 4)], + ['a missing SSH generation', 'ssh:target-1', sshState('target-1', undefined)] + ])('rejects %s', (_name, hostId, state) => { + expect(() => buildMobileFileMutationOwnership(hostId, state)).toThrow( + "Couldn't verify the SSH connection" + ) + }) + + it('captures local ownership only after verifying the runtime capability', async () => { + const { client, sendRequest } = clientWithResponses([ + success({ capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] }), + success({ worktree: { hostId: 'local' } }) + ]) + + await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).resolves.toEqual({ + expectedExecutionHostId: 'local' + }) + expect(sendRequest.mock.calls).toEqual([ + ['status.get', undefined, { timeoutMs: 15_000 }], + ['worktree.show', { worktree: 'id:worktree-1' }, { timeoutMs: 15_000 }] + ]) + }) + + it('captures SSH generation from the HUB before building mutation params', async () => { + const state = sshState('target-1', 9) + const { client, sendRequest } = clientWithResponses([ + success({ capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] }), + success({ worktree: { hostId: 'ssh:target-1' } }), + success({ state }) + ]) + + await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).resolves.toEqual({ + expectedExecutionHostId: 'ssh:target-1', + expectedSshTargetId: 'target-1', + expectedSshConnectionGeneration: 9 + }) + expect(sendRequest.mock.calls[2]).toEqual([ + 'ssh.getState', + { targetId: 'target-1' }, + { timeoutMs: 15_000 } + ]) + }) + + it('refuses older runtimes before reading or mutating workspace files', async () => { + const { client, sendRequest } = clientWithResponses([success({ capabilities: [] })]) + + await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).rejects.toThrow( + FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE + ) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/files/mobile-file-mutation-ownership.ts b/mobile/src/files/mobile-file-mutation-ownership.ts new file mode 100644 index 00000000000..e5a1cbbeb65 --- /dev/null +++ b/mobile/src/files/mobile-file-mutation-ownership.ts @@ -0,0 +1,81 @@ +import { parseExecutionHostId } from '../../../src/shared/execution-host' +import { assertFileMutationOwnershipCapability } from '../../../src/shared/file-mutation-ownership' +import type { RuntimeStatus } from '../../../src/shared/runtime-types' +import type { SshConnectionState, SshMutationExpectation } from '../../../src/shared/ssh-types' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcFailure, RpcSuccess } from '../transport/types' + +const FILE_MUTATION_TIMEOUT_MS = 15_000 +const SSH_OWNER_CHANGED_MESSAGE = + "Couldn't verify the SSH connection. Reconnect the host and try again." + +export type MobileFileMutationOwnership = SshMutationExpectation & { + expectedExecutionHostId: 'local' | `ssh:${string}` +} + +export function buildMobileFileMutationOwnership( + worktreeHostId: string | null | undefined, + sshState: SshConnectionState | null = null +): MobileFileMutationOwnership { + const host = parseExecutionHostId(worktreeHostId) + if (worktreeHostId !== undefined && !host) { + throw new Error(SSH_OWNER_CHANGED_MESSAGE) + } + if (!host || host.kind === 'local' || host.kind === 'runtime') { + return { expectedExecutionHostId: 'local' } + } + if (sshState?.targetId !== host.targetId || sshState.connectionGeneration === undefined) { + throw new Error(SSH_OWNER_CHANGED_MESSAGE) + } + return { + expectedExecutionHostId: host.id, + expectedSshTargetId: host.targetId, + expectedSshConnectionGeneration: sshState.connectionGeneration + } +} + +export async function captureMobileFileMutationOwnership( + client: Pick, + worktree: string +): Promise { + const status = await requestResult>( + client, + 'status.get', + undefined + ) + assertFileMutationOwnershipCapability(status) + + const result = await requestResult<{ worktree?: { hostId?: string | null } }>( + client, + 'worktree.show', + { worktree } + ) + if (!result.worktree) { + throw new Error(SSH_OWNER_CHANGED_MESSAGE) + } + + const host = parseExecutionHostId(result.worktree.hostId) + const sshState = + host?.kind === 'ssh' + ? ( + await requestResult<{ state: SshConnectionState | null }>(client, 'ssh.getState', { + targetId: host.targetId + }) + ).state + : null + return buildMobileFileMutationOwnership(result.worktree.hostId, sshState) +} + +async function requestResult( + client: Pick, + method: string, + params: unknown +): Promise { + const response = await client.sendRequest(method, params, { + timeoutMs: FILE_MUTATION_TIMEOUT_MS + }) + if (!response.ok) { + throw new Error((response as RpcFailure).error.message) + } + return (response as RpcSuccess).result as TResult +}