fix(mobile): run a server workspace's SSH calls on that server

A phone in a server workspace asks for SSH state before a file mutation, but the Mac answered
about its own targets, so server-owned SSH repos failed. The desktop asks the server, so the
ssh.* family now routes by target; an e2e pins files, uploads, commits and review on the server.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-08 04:49:59 -04:00
parent 5f886b246c
commit 71cab3cd8a
5 changed files with 194 additions and 7 deletions
@@ -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')
@@ -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)
@@ -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',
@@ -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
@@ -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<Frame> {
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<unknown> {
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()
}
})