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()
+ }
+})