mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
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:
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user