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 9a9542d41f0..e21d2b3b3d2 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 @@ -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 = { diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.ts index 3ef48699344..20386ee042c 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.ts @@ -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( diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index e4f084b0e89..a94018ea8ba 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -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'))