diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts index 3205398fa5c..994137a4536 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts @@ -464,6 +464,20 @@ describe('mobile desktop relay: phone -> desktop -> server', () => { expect(server.capabilities).toEqual(host.runtime.getStatus().capabilities) }) + it("answers a targeted SSH state from the server, which holds a server workspace's SSH targets", async () => { + const { desktop } = await startTopology() + const phone = await connectPhone(desktop.server, 'iPhone') + const answeredByServer = (id: string) => + passthroughOpens.hostReplies.some((frame) => JSON.parse(frame).id === id) + + phone.send('ssh:server', 'ssh.getState', { targetId: 'devbox' }) + phone.send('ssh:desktop', 'ssh.getState', { targetId: 'devbox' }, null) + expect(isOk(await phone.next('ssh:server'))).toBe(true) + expect(isOk(await phone.next('ssh:desktop'))).toBe(true) + expect(answeredByServer('ssh:server')).toBe(true) + expect(answeredByServer('ssh:desktop')).toBe(false) + }) + it('refuses binary-frame methods and unknown servers without running anything on the desktop', async () => { const { desktop, desktopSpawns } = await startTopology() const phone = await connectPhone(desktop.server, 'iPhone') diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.test.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.test.ts index 782075effa9..1b18a30a1c0 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.test.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.test.ts @@ -21,6 +21,18 @@ describe('mobile RPC method routing census', () => { expect(MOBILE_RPC_METHOD_ROUTES.get('status.get')).toBe('execution-host') }) + it("sends targeted SSH calls to the server, which holds a server workspace's SSH targets", () => { + for (const method of [ + 'ssh.connect', + 'ssh.getState', + 'ssh.listRemovedTargetLabels', + 'ssh.listTargetSummaries', + 'ssh.listTargets' + ]) { + expect(MOBILE_RPC_METHOD_ROUTES.get(method), method).toBe('execution-host') + } + }) + it('tags nothing outside the allowlist', () => { const stale = [...MOBILE_RPC_METHOD_ROUTES.keys()].filter( (method) => !MOBILE_RPC_METHOD_ALLOWLIST.has(method) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts index 33efcf0c7d3..eef424876d6 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts @@ -147,6 +147,12 @@ export const EXECUTION_HOST_METHODS = [ 'repo.searchRefs', 'repo.sparsePresets', 'repo.update', + // SSH targets belong to the host that holds them; untargeted calls stay the Mac's own, as on the desktop. + 'ssh.connect', + 'ssh.getState', + 'ssh.listRemovedTargetLabels', + 'ssh.listTargetSummaries', + 'ssh.listTargets', // A targeted status is the server's own, so a server workspace gates on what that server supports. 'status.get', 'session.tabs.activate', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts index 82097fa21b4..20401fc061a 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts @@ -95,11 +95,5 @@ export const PAIRED_DESKTOP_METHODS = [ 'settings.update', 'settings.updateTerminalQuickCommands', 'terminal.getAutoRestoreFit', - 'terminal.setAutoRestoreFit', - // SSH targets are configured on the Mac. - 'ssh.connect', - 'ssh.getState', - 'ssh.listRemovedTargetLabels', - 'ssh.listTargetSummaries', - 'ssh.listTargets' + 'terminal.setAutoRestoreFit' ] as const diff --git a/tests/e2e/paired-mobile-desktop-mirror-files-git-review.spec.ts b/tests/e2e/paired-mobile-desktop-mirror-files-git-review.spec.ts new file mode 100644 index 00000000000..03bf0a5f0a5 --- /dev/null +++ b/tests/e2e/paired-mobile-desktop-mirror-files-git-review.spec.ts @@ -0,0 +1,161 @@ +/** + * Files, Source Control and review for a server workspace, from a phone paired with the desktop: + * each call names the server with `executionHost`, and its effect lands on the server, never the Mac. + */ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { z } from 'zod' +import { expect, test } from './helpers/orca-app' +import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology' +import type { PairedMobileSocket } from './helpers/paired-mobile-client' + +type Frame = PairedMobileSocket['frames'][number] + +const HostWorktreesSchema = z.looseObject({ + worktrees: z.array(z.looseObject({ worktreeId: z.string(), path: z.string() })), + stale: z.boolean() +}) +const ReposSchema = z.object({ + repos: z.array(z.looseObject({ id: z.string(), path: z.string() })) +}) + +let requestCount = 0 + +async function send( + socket: PairedMobileSocket, + method: string, + params: unknown, + executionHost?: string +): Promise { + requestCount += 1 + const id = `${method}-${requestCount}` + socket.send(id, method, params, executionHost) + let found: Frame | undefined + await expect + .poll(() => (found = socket.frames.find((frame) => frame.id === id)), { timeout: 30_000 }) + .toBeDefined() + return found! +} + +async function result( + socket: PairedMobileSocket, + method: string, + params: unknown, + executionHost?: string +): Promise { + const frame = await send(socket, method, params, executionHost) + expect(frame.ok, `${method}: ${JSON.stringify(frame.error)}`).toBe(true) + return frame.result +} + +function gitRepo(dir: string, remote: string): (...args: string[]) => string { + mkdirSync(dir, { recursive: true }) + const git = (...args: string[]) => + execFileSync('git', args, { cwd: dir, stdio: 'pipe', encoding: 'utf8' }) + writeFileSync(path.join(dir, 'NOTES.md'), 'first\n') + git('init', '-b', 'main') + // Why: the server runs with an isolated HOME, so the repo carries its own identity. + git('config', 'user.name', 'E2E') + git('config', 'user.email', 'e2e@test.local') + git('remote', 'add', 'origin', remote) + git('add', 'NOTES.md') + git('commit', '-m', 'init') + return git +} + +test('phone reads, uploads, commits and reviews in a server workspace through the desktop', async () => { + const testInfo = test.info() + test.setTimeout(180_000) + const githubRepo = testInfo.outputPath('server-github-repo') + const gitlabRepo = testInfo.outputPath('server-gitlab-repo') + const git = gitRepo(githubRepo, 'https://github.com/acme/server-github-repo.git') + gitRepo(gitlabRepo, 'https://gitlab.com/acme/server-gitlab-repo.git') + writeFileSync(path.join(githubRepo, 'NOTES.md'), 'changed on the server\n') + + const { host, phone, dispose } = await launchPhoneMirrorTopology({ phoneTo: 'desktop' }, testInfo) + try { + await host.client.call('repo.add', { path: githubRepo, kind: 'git' }) + await host.client.call('repo.add', { path: gitlabRepo, kind: 'git' }) + const socket = await phone.openSocket() + const hosts = z + .object({ hosts: z.array(z.looseObject({ hostId: z.string() })) }) + .parse(await result(socket, 'mobileRelay.hosts.list', {})) + const server = hosts.hosts.find((row) => row.hostId.startsWith('runtime:'))?.hostId ?? '' + expect(server).not.toBe('') + let worktreeId = '' + await expect + .poll( + async () => { + const listed = HostWorktreesSchema.safeParse( + await result(socket, 'mobileRelay.hosts.worktrees', { hostId: server }) + ) + worktreeId = + listed.data?.worktrees.find((row) => row.path === githubRepo)?.worktreeId ?? '' + return worktreeId + }, + { timeout: 30_000 } + ) + .not.toBe('') + const worktree = `id:${worktreeId}` + + // Open: the server's file, read on the server. + const opened = z + .looseObject({ content: z.string() }) + .parse(await result(socket, 'files.read', { worktree, relativePath: 'NOTES.md' }, server)) + expect(opened.content).toBe('changed on the server\n') + + // Upload: the slot lives in the server process, so the Mac knows nothing of it. + const image = Buffer.from('server-upload').toString('base64') + const { uploadId } = z + .object({ uploadId: z.string() }) + .parse( + await result( + socket, + 'clipboard.startImageUpload', + { expectedBase64Length: image.length }, + server + ) + ) + const chunk = { uploadId, offset: 0, contentBase64: image } + const onMac = await send(socket, 'clipboard.appendImageUploadChunk', chunk) + expect(onMac.ok, 'the upload slot is not on the desktop').toBe(false) + await result(socket, 'clipboard.appendImageUploadChunk', chunk, server) + const uploaded = z + .string() + .parse(await result(socket, 'clipboard.commitImageUpload', { uploadId }, server)) + expect(existsSync(uploaded)).toBe(true) + + // Commit: lands in the server's repository. + await result(socket, 'git.stage', { worktree, filePath: 'NOTES.md' }, server) + expect( + await result(socket, 'git.commit', { worktree, message: 'from the phone' }, server) + ).toMatchObject({ success: true }) + expect(git('log', '-1', '--format=%s').trim()).toBe('from the phone') + expect(git('status', '--porcelain').trim()).toBe('') + + // Review: provider calls run on the server's repository, as the desktop runs them. + const { repos } = ReposSchema.parse(await result(socket, 'repo.list', {}, server)) + const repoId = (repoPath: string) => repos.find((repo) => repo.path === repoPath)?.id ?? '' + expect( + await result(socket, 'github.repoSlug', { repo: repoId(githubRepo) }, server) + ).toMatchObject({ owner: 'acme', repo: 'server-github-repo' }) + const untargeted = await send(socket, 'github.repoSlug', { repo: repoId(githubRepo) }) + expect(untargeted.ok, 'the desktop does not hold the server repo').toBe(false) + for (const [repoPath, provider] of [ + [githubRepo, 'github'], + [gitlabRepo, 'gitlab'] + ] as const) { + const eligibility = await result( + socket, + 'hostedReview.getCreationEligibility', + { repo: repoId(repoPath), branch: 'main' }, + server + ) + await testInfo.attach(`eligibility-${provider}`, { body: JSON.stringify(eligibility) }) + expect(eligibility).toMatchObject({ provider }) + } + } finally { + await dispose() + } +})