fix(mobile): run targeted desktop-owned calls on the desktop

The phone tags every call on a workspace screen with its execution host, so a
paired-desktop method carrying `runtime:<env>` now runs locally instead of
failing 'forbidden'. The Mac stays the only reader of the routing table.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-08 01:00:12 -04:00
parent d154002bca
commit def35508ec
3 changed files with 27 additions and 11 deletions
@@ -422,12 +422,30 @@ describe('mobile desktop relay: phone -> desktop -> server', () => {
expect(isOk(await phone.next('after-sync'))).toBe(true)
})
it('refuses desktop-owned methods and unknown servers without running anything on the desktop', async () => {
it('answers a targeted desktop-owned call on the desktop, exactly as the untargeted one', async () => {
const { desktop } = await startTopology()
const phone = await connectPhone(desktop.server, 'iPhone')
const opensBefore = passthroughOpens.count
for (const method of ['settings.get', 'status.get']) {
phone.send(`${method}:targeted`, method, {})
phone.send(`${method}:untargeted`, method, {}, null)
const targeted = await phone.next(`${method}:targeted`)
const untargeted = await phone.next(`${method}:untargeted`)
expect(isOk(targeted)).toBe(true)
expect(targeted.result).toEqual(untargeted.result)
}
expect(passthroughOpens.count).toBe(opensBefore)
// An execution-host method under the same target still relays.
phone.send('list', 'terminal.list', {})
expect(isOk(await phone.next('list'))).toBe(true)
expect(passthroughOpens.count).toBe(opensBefore + 1)
})
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')
phone.send('settings', 'settings.get', {})
expect(errorCode(await phone.next('settings'))).toBe('forbidden')
phone.send('multiplex', 'terminal.multiplex', {})
expect(errorCode(await phone.next('multiplex'))).toBe('forbidden')
const create = {
@@ -6,7 +6,6 @@ import {
openRemoteRuntimePassthroughSocket,
type RemoteRuntimePassthroughSocket
} from '../../../shared/remote-runtime-passthrough-socket'
import { MOBILE_RPC_METHOD_ROUTES } from '../runtime-rpc/runtime-rpc-mobile-method-routing'
import { syncDelegatedPhoneGrants, type DelegatedPhoneGrants } from './delegated-phone-grants'
import type { MobileDesktopRelayHost, MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts'
import { RELAYED_STREAM_CARRIERS } from './relayed-stream-carriers'
@@ -53,7 +52,7 @@ class RelayLink {
* `pairing.delegatedMobileDevice.sync`, tokens held in memory only). Contract:
* - The desktop never interprets replies; it renumbers only host stream ids (RELAYED_STREAM_CARRIERS).
* - Upstream, the phone's desktop token is swapped wherever it appears, not per field.
* - Only `execution-host` methods relay (MOBILE_RPC_METHOD_ROUTES); both tables have census tests.
* - Only `execution-host` methods (MOBILE_RPC_METHOD_ROUTES) reach here; `paired-desktop` ones run locally.
* - A server without the delegated-devices capability is `update-needed`; nothing relays to it.
* - A server-side revoke of a phone is final until the next sync.
* - Hidden until S2: MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY is not advertised yet.
@@ -89,12 +88,6 @@ export class MobileDesktopRelay {
request: { id: string; method: string },
frame: string
): void {
if (MOBILE_RPC_METHOD_ROUTES.get(request.method) !== 'execution-host') {
phone.reply(
failure(request.id, 'forbidden', `Method '${request.method}' runs on the paired desktop`)
)
return
}
if (RELAYED_STREAM_CARRIERS.get(request.method) === 'phone-binary-frames') {
phone.reply(
failure(
@@ -11,6 +11,7 @@ import type { DeviceScope } from '../device-registry'
import { RuntimeRpcMobileDevices } from './runtime-rpc-mobile-devices'
import { classifyRuntimeLongPoll } from './runtime-rpc-long-poll'
import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc-mobile-method-allowlist'
import { MOBILE_RPC_METHOD_ROUTES } from './runtime-rpc-mobile-method-routing'
import { parseExecutionHostId } from '../../../shared/execution-host'
// Why: status.get has no per-connection context in the dispatcher, so stamp the scope here at the transport boundary.
@@ -45,6 +46,10 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices {
if (target?.kind === 'local' || target?.kind === 'ssh') {
return false
}
// Why: the phone tags every call on a workspace screen; only this Mac's routing table decides.
if (target && MOBILE_RPC_METHOD_ROUTES.get(request.method) === 'paired-desktop') {
return false
}
if (!target) {
reply(
JSON.stringify(this.buildError(request.id, 'invalid_argument', 'Invalid executionHost'))