From ee5fbcd3d6460e1583af010027b2dcad38c47f2e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 03:42:33 -0400 Subject: [PATCH] feat(mobile): gate a server workspace on that server's own features A targeted status.get now relays to the server, so a server workspace's route provides that server's status to every screen under it and its action sheet, while whether the phone can reach the server stays the desktop's answer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../src/host-screen/host-screen-overlays.tsx | 19 ++- ...ile-web-shell-agent-history-route.test.tsx | 3 + .../mobile-web-shell-files-route.test.tsx | 3 + .../mobile-web-shell-session-route.test.tsx | 3 + .../shell-switch-null-flag.test.tsx | 3 + .../src/navigation/workspace-route.test.tsx | 139 +++++++++++++----- mobile/src/navigation/workspace-route.tsx | 40 +++-- .../transport/use-workspace-client.test.tsx | 79 ---------- mobile/src/transport/use-workspace-client.ts | 16 +- mobile/src/transport/workspace-server.ts | 30 ++++ .../mobile-desktop-relay.integration.test.ts | 27 +++- .../runtime-rpc-mobile-method-routing.test.ts | 4 + .../runtime-rpc-mobile-method-routing.ts | 2 + ...ntime-rpc-mobile-paired-desktop-methods.ts | 1 - 14 files changed, 216 insertions(+), 153 deletions(-) delete mode 100644 mobile/src/transport/use-workspace-client.test.tsx create mode 100644 mobile/src/transport/workspace-server.ts diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index ba97420cd0c..360eccbea18 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -2,7 +2,7 @@ import { openExternalLink } from '../platform/external-link' import { Pressable, Text, View } from 'react-native' import { Check, Moon } from 'lucide-react-native' import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions' -import { workspaceRouteExecutionHost } from '../transport/execution-host-scoped-rpc-client' +import { useWorkspaceServer } from '../transport/workspace-server' import { ActionSheetContent } from '../components/ActionSheetModal' import { BottomDrawer } from '../components/BottomDrawer' import { ConfirmModal } from '../components/ConfirmModal' @@ -25,6 +25,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr actions, catalog, client, + connState, existingWorktreePaths, hostCapabilities, hostId, @@ -33,6 +34,13 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr state } = controller const actionTarget = state.actionTarget + // A server row's screens are offered by what that server supports, not the desktop. + const actionServer = useWorkspaceServer({ + client, + connState, + desktopCapabilities: hostCapabilities, + executionHost: actionTarget?.hostId + }) return ( <> @@ -165,12 +173,9 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr hostId, worktreeId: actionTarget.worktreeId, worktreeName: actionTarget.displayName || actionTarget.repo, - executionHost: workspaceRouteExecutionHost( - client, - hostCapabilities, - actionTarget.hostId - ), - hostCapabilities, + executionHost: actionServer.routeHost, + hostCapabilities: + actionServer.server?.gates.hostCapabilities ?? hostCapabilities, navigate: actions.navigateFromHostList, onDone: () => state.setActionTarget(null) }), diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx index 80d510fbb41..c64e9fd3a51 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx @@ -39,6 +39,9 @@ vi.mock('react-native', () => ({ vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) +vi.mock('../transport/workspace-server', () => ({ + useWorkspaceServer: () => ({ routeHost: undefined, server: null }) +})) vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params })) vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({ diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx index ae6c25a4192..b9122675ebb 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx @@ -41,6 +41,9 @@ vi.mock('react-native', () => ({ vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) +vi.mock('../transport/workspace-server', () => ({ + useWorkspaceServer: () => ({ routeHost: undefined, server: null }) +})) vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params })) vi.mock('../files/MobileFileExplorerPanel', () => ({ diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx index fb04c910c83..19c417c4fa1 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx @@ -42,6 +42,9 @@ vi.mock('react-native', () => ({ vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) +vi.mock('../transport/workspace-server', () => ({ + useWorkspaceServer: () => ({ routeHost: undefined, server: null }) +})) vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params, useRouter: () => ({ diff --git a/mobile/src/mobile-web-shell/shell-switch-null-flag.test.tsx b/mobile/src/mobile-web-shell/shell-switch-null-flag.test.tsx index cfe9088c79f..6fd027f14bc 100644 --- a/mobile/src/mobile-web-shell/shell-switch-null-flag.test.tsx +++ b/mobile/src/mobile-web-shell/shell-switch-null-flag.test.tsx @@ -60,6 +60,9 @@ vi.mock('react-native', () => ({ vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) +vi.mock('../transport/workspace-server', () => ({ + useWorkspaceServer: () => ({ routeHost: undefined, server: null }) +})) vi.mock('expo-router', () => ({ Redirect: 'Redirect', useLocalSearchParams: () => dependencies.params, diff --git a/mobile/src/navigation/workspace-route.test.tsx b/mobile/src/navigation/workspace-route.test.tsx index 5d7666b81cf..df6e98a67d3 100644 --- a/mobile/src/navigation/workspace-route.test.tsx +++ b/mobile/src/navigation/workspace-route.test.tsx @@ -7,15 +7,13 @@ import { describe, expect, it, vi } from 'vitest' const route = vi.hoisted(() => { const state: { params: Record - hostCapabilities: string[] - statusPending: boolean + client: unknown seen: (string | undefined)[] shellRoutes: { params?: Record }[] redirects: { params: Record }[] } = { params: {}, - hostCapabilities: [], - statusPending: false, + client: null, seen: [], shellRoutes: [], redirects: [] @@ -38,13 +36,12 @@ vi.mock('react-native', () => ({ Pressable: 'Pressable' })) vi.mock('../transport/client-context', () => ({ - useHostClient: () => ({ client: {}, state: 'connected' }) + useHostClient: () => ({ client: route.client, state: 'connected' }) })) -vi.mock('../components/host-protocol-gates-context', () => ({ - useOptionalHostProtocolGates: () => ({ - hostCapabilities: route.hostCapabilities, - statusPending: route.statusPending - }) +// The server's status is read for real; only the desktop's persisted records are stubbed. +vi.mock('../transport/host-app-version-store', () => ({ recordHostAppVersion: async () => {} })) +vi.mock('../transport/host-descriptor-recorder', () => ({ + recordHostDescriptorFromStatus: () => {} })) vi.mock('./route-handoff', () => ({ useRouteHandoff: () => ({ replace: () => {} }) })) // The native switch always hands the page its route, so the census reads what the page is told. @@ -72,14 +69,58 @@ vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({ })) import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract' +import { + HostStatusGatesContext, + useOptionalHostProtocolGates +} from '../components/host-protocol-gates-context' +import type { HostStatusGates } from '../transport/host-status-gates' +import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' +import type { RpcClient } from '../transport/rpc-client' +import { useWorkspaceClient } from '../transport/use-workspace-client' import { useWorkspaceExecutionHost } from './workspace-execution-host' import { WorkspaceRoute } from './workspace-route' +const RELAYS = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY] +const VAULT = 'aiVault.v1' + +/** What a screen under the route sees: its server, its client and its feature gates. */ +const below: { client: RpcClient | null; capabilities: readonly string[] | null } = { + client: null, + capabilities: null +} + function Probe(): null { route.seen.push(useWorkspaceExecutionHost()) + below.client = useWorkspaceClient('host-1').client + below.capabilities = useOptionalHostProtocolGates()?.hostCapabilities ?? null return null } +function desktopGates(hostCapabilities: string[], statusPending = false): HostStatusGates { + return { + hostCapabilities, + floatingWorkspaceEnabled: false, + desktopAppVersion: null, + compatVerdict: { kind: 'ok' }, + hostProtocolWindow: { protocolVersion: undefined, minCompatibleMobileVersion: undefined }, + statusPending, + statusReadable: true + } +} + +/** A desktop whose own status is settled as given; a status it is asked for is the server's. */ +function desktop(serverCapabilities: string[] = []): FakeSession { + const session = new FakeSession('connected') + session.sendRequest.mockImplementation(async (method: string) => ({ + id: 'reply', + ok: true, + result: method === 'status.get' ? { capabilities: serverCapabilities } : {}, + _meta: { runtimeId: 'runtime-1' } + })) + route.client = session + return session +} + const HOST_ROUTES = fileURLToPath(new URL('../../app/h/[hostId]/', import.meta.url)) // Every screen keyed by a workspace, native and page, found on disk so a new one is counted. const WORKSPACE_ROUTE_FILES = readdirSync(HOST_ROUTES, { recursive: true, encoding: 'utf8' }) @@ -87,13 +128,15 @@ const WORKSPACE_ROUTE_FILES = readdirSync(HOST_ROUTES, { recursive: true, encodi .filter((entry) => /(^|\/)\[worktreeId\](\.web)?\.tsx$/.test(entry)) .sort() -async function render(element: ReactElement): Promise { +async function render(element: ReactElement, gates: HostStatusGates): Promise { route.seen.length = 0 route.shellRoutes.length = 0 route.redirects.length = 0 + below.client = null + below.capabilities = null let renderer: ReactTestRenderer | null = null await act(async () => { - renderer = create(element) + renderer = create(createElement(HostStatusGatesContext.Provider, { value: gates }, element)) }) if (!renderer) { throw new Error('did not render') @@ -120,54 +163,78 @@ describe('every workspace route', () => { // Spelled loosely, so a screen reading the raw param instead of its route is caught. executionHost: ' runtime:vm ' } - route.hostCapabilities = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY] - route.statusPending = false + desktop() const Screen = (await import(/* @vite-ignore */ `${HOST_ROUTES}${file}`)).default - await render(createElement(Screen)) + await render(createElement(Screen), desktopGates(RELAYS)) if (route.redirects.length > 0) { expect(route.redirects.map((href) => href.params.executionHost?.trim())).toEqual([ 'runtime:vm' ]) return } - expect(route.seen).toEqual(['runtime:vm']) + expect([...new Set(route.seen)]).toEqual(['runtime:vm']) for (const shellRoute of route.shellRoutes) { expect(shellRoute.params?.executionHost).toBe('runtime:vm') } }) }) -describe('a server workspace the phone cannot reach', () => { - const child = createElement(Probe) +describe('a server workspace', () => { + const screen = createElement(WorkspaceRoute, null, createElement(Probe)) - it('says so once the desktop has answered, instead of waiting forever', async () => { + it('runs every call on that server', async () => { route.params = { hostId: 'host-1', executionHost: 'runtime:vm' } - route.hostCapabilities = [] - route.statusPending = false - const renderer = await render(createElement(WorkspaceRoute, null, child)) + const session = desktop() + await render(screen, desktopGates(RELAYS)) + await below.client?.sendRequest('files.readDir', { worktree: 'id:wt' }) + expect(session.sendRequest).toHaveBeenCalledWith( + 'files.readDir', + { worktree: 'id:wt' }, + { executionHost: 'runtime:vm' } + ) + }) + + it('gates its features on that server’s own status, not the desktop’s', async () => { + route.params = { hostId: 'host-1', executionHost: 'runtime:vm' } + const session = desktop([]) + await render(screen, desktopGates([...RELAYS, VAULT])) + await vi.waitFor(() => expect(below.capabilities).toEqual([])) + expect(session.sendRequest).toHaveBeenCalledWith('status.get', undefined, { + executionHost: 'runtime:vm' + }) + }) + + it('says so once the desktop has answered that it cannot reach the server', async () => { + route.params = { hostId: 'host-1', executionHost: 'runtime:vm' } + const session = desktop() + const renderer = await render(screen, desktopGates([])) expect(route.seen).toEqual([]) expect(texts(renderer)).toContain( "Your phone can't reach this workspace's server through this desktop." ) + expect(session.sendRequest).not.toHaveBeenCalled() }) - it('waits while the desktop has not answered, and opens a reachable one', async () => { + it('waits, never on the desktop, while the desktop has not answered', async () => { route.params = { hostId: 'host-1', executionHost: 'runtime:vm' } - route.hostCapabilities = [] - route.statusPending = true - await render(createElement(WorkspaceRoute, null, child)) - expect(route.seen).toEqual(['runtime:vm']) - route.hostCapabilities = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY] - route.statusPending = false - await render(createElement(WorkspaceRoute, null, child)) - expect(route.seen).toEqual(['runtime:vm']) + const session = desktop() + await render(screen, desktopGates([], true)) + expect([...new Set(route.seen)]).toEqual(['runtime:vm']) + expect(below.client).toBeNull() + expect(session.sendRequest).not.toHaveBeenCalled() }) +}) - it('never stands in for the desktop’s own workspaces', async () => { +describe('the desktop’s own workspace', () => { + it('keeps the desktop’s client and gates', async () => { route.params = { hostId: 'host-1' } - route.hostCapabilities = [] - route.statusPending = false - await render(createElement(WorkspaceRoute, null, child)) - expect(route.seen).toEqual([undefined]) + const session = desktop() + await render( + createElement(WorkspaceRoute, null, createElement(Probe)), + desktopGates([...RELAYS, VAULT]) + ) + expect([...new Set(route.seen)]).toEqual([undefined]) + expect(below.client).toBe(session) + expect(below.capabilities).toEqual([...RELAYS, VAULT]) }) }) diff --git a/mobile/src/navigation/workspace-route.tsx b/mobile/src/navigation/workspace-route.tsx index f38bf178dc4..b738334b6d1 100644 --- a/mobile/src/navigation/workspace-route.tsx +++ b/mobile/src/navigation/workspace-route.tsx @@ -3,18 +3,24 @@ import { useLocalSearchParams } from 'expo-router' import { normalizeExecutionHostId } from '../../../src/shared/execution-host' import { firstParam } from './route-param-reader' import { WorkspaceExecutionHostContext } from './workspace-execution-host' -import { useOptionalHostProtocolGates } from '../components/host-protocol-gates-context' +import { + HostStatusGatesContext, + useOptionalHostProtocolGates +} from '../components/host-protocol-gates-context' import { PageRouteUnavailableScreen } from '../mobile-web-shell/PageRouteUnavailableScreen' import { useHostClient } from '../transport/client-context' -import { workspaceRouteExecutionHost } from '../transport/execution-host-scoped-rpc-client' +import { WorkspaceServerClientContext } from '../transport/use-workspace-client' +import { useWorkspaceServer } from '../transport/workspace-server' +const NO_CAPABILITIES: readonly string[] = [] const UNREACHABLE_SERVER_MESSAGE = "Your phone can't reach this workspace's server through this desktop." /** * A workspace route's server, read once from its `executionHost` param: every call made and every - * route pushed below it stays on that server. Absent for the desktop's own workspaces; a server - * this phone can't reach (older desktop or shell, unreadable status) gets one explicit dead end. + * route pushed below it stays on that server, and every feature gate below reads that server's own + * status. Absent for the desktop's own workspaces; a server this phone can't reach (older desktop + * or shell, unreadable status) gets one explicit dead end. */ export function WorkspaceRoute({ children }: { children: ReactNode }) { const params = useLocalSearchParams<{ @@ -24,21 +30,31 @@ export function WorkspaceRoute({ children }: { children: ReactNode }) { const hostId = firstParam(params.hostId) const executionHost = normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined const { client, state } = useHostClient(hostId) - const gates = useOptionalHostProtocolGates() + const desktopGates = useOptionalHostProtocolGates() + const { routeHost, server } = useWorkspaceServer({ + client, + connState: state, + desktopCapabilities: desktopGates?.hostCapabilities ?? NO_CAPABILITIES, + executionHost + }) // Why: once the desktop has answered, a server it can't reach for this phone is a dead end, not a spinner. - if ( - state === 'connected' && - gates && - !gates.statusPending && - workspaceRouteExecutionHost(client, gates.hostCapabilities, executionHost) === null - ) { + if (state === 'connected' && desktopGates && !desktopGates.statusPending && routeHost === null) { return } - return ( + const screens = ( {children} ) + return server ? ( + + + {screens} + + + ) : ( + screens + ) } /** Wraps a workspace route's default export so everything under it knows the workspace's server. */ diff --git a/mobile/src/transport/use-workspace-client.test.tsx b/mobile/src/transport/use-workspace-client.test.tsx deleted file mode 100644 index 4931b37f5f3..00000000000 --- a/mobile/src/transport/use-workspace-client.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { createElement } from 'react' -import { act, create } from 'react-test-renderer' -import { describe, expect, it, vi } from 'vitest' - -const route = vi.hoisted(() => { - const state: { capabilities: string[] } = { - capabilities: [] - } - return state -}) -const desktop = vi.hoisted(() => { - const state: { client: unknown } = { client: null } - return state -}) - -vi.mock('../components/host-protocol-gates-context', () => ({ - useOptionalHostProtocolGates: () => ({ hostCapabilities: route.capabilities }) -})) -vi.mock('./client-context', () => ({ - useHostClient: () => ({ client: desktop.client, clientId: 'client-1', state: 'connected' }) -})) - -import { normalizeExecutionHostId } from '../../../src/shared/execution-host' -import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract' -import { WorkspaceExecutionHostContext } from '../navigation/workspace-execution-host' -import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' -import type { RpcClient } from './rpc-client' -import { useWorkspaceClient } from './use-workspace-client' - -async function workspaceClientFor( - params: Record, - capabilities: string[] -): Promise<{ client: RpcClient | null; session: FakeSession }> { - route.capabilities = capabilities - const session = new FakeSession('connected') - desktop.client = session - const held: { client: RpcClient | null } = { client: null } - function Probe(): null { - held.client = useWorkspaceClient('host-1').client - return null - } - await act(async () => { - create( - createElement( - WorkspaceExecutionHostContext.Provider, - { value: normalizeExecutionHostId(params.executionHost) ?? undefined }, - createElement(Probe) - ) - ) - }) - return { client: held.client, session } -} - -describe('a workspace screen’s client', () => { - it('runs every call of a server workspace on that server', async () => { - const { client, session } = await workspaceClientFor({ executionHost: 'runtime:vm' }, [ - MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY - ]) - await client?.sendRequest('files.readDir', { worktree: 'id:wt' }) - expect(session.sendRequest).toHaveBeenCalledWith( - 'files.readDir', - { worktree: 'id:wt' }, - { executionHost: 'runtime:vm' } - ) - }) - - it('never falls back to the desktop for a server workspace it cannot reach yet', async () => { - const { client, session } = await workspaceClientFor({ executionHost: 'runtime:vm' }, []) - expect(client).toBeNull() - expect(session.sendRequest).not.toHaveBeenCalled() - }) - - it('keeps the desktop’s own client for the desktop’s own workspaces', async () => { - const { client, session } = await workspaceClientFor({}, [ - MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY - ]) - expect(client).toBe(session) - }) -}) diff --git a/mobile/src/transport/use-workspace-client.ts b/mobile/src/transport/use-workspace-client.ts index 10150b30b53..61aa443a2ea 100644 --- a/mobile/src/transport/use-workspace-client.ts +++ b/mobile/src/transport/use-workspace-client.ts @@ -1,17 +1,13 @@ -import { useOptionalHostProtocolGates } from '../components/host-protocol-gates-context' -import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host' +import { createContext, useContext } from 'react' import { useHostClient } from './client-context' -import { rpcClientForExecutionHost } from './execution-host-scoped-rpc-client' +import type { RpcClient } from './rpc-client' -const NO_CAPABILITIES: readonly string[] = [] +// Provided by `WorkspaceRoute` for a server's workspace (null until reachable); absent otherwise. +export const WorkspaceServerClientContext = createContext(undefined) /** `useHostClient` for a workspace screen: its calls run where the workspace does. */ export function useWorkspaceClient(hostId: string | undefined): ReturnType { const host = useHostClient(hostId) - const hostCapabilities = useOptionalHostProtocolGates()?.hostCapabilities ?? NO_CAPABILITIES - const executionHost = useWorkspaceExecutionHost() - return { - ...host, - client: host.client && rpcClientForExecutionHost(host.client, hostCapabilities, executionHost) - } + const serverClient = useContext(WorkspaceServerClientContext) + return serverClient === undefined ? host : { ...host, client: serverClient } } diff --git a/mobile/src/transport/workspace-server.ts b/mobile/src/transport/workspace-server.ts new file mode 100644 index 00000000000..2b0d6fbb8fe --- /dev/null +++ b/mobile/src/transport/workspace-server.ts @@ -0,0 +1,30 @@ +import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { + scopeRpcClientToExecutionHost, + workspaceRouteExecutionHost +} from './execution-host-scoped-rpc-client' +import { useHostStatusGates, type HostStatusGates } from './host-status-gates' +import type { RpcClient } from './rpc-client' +import type { ConnectionState } from './types' + +/** + * Where a workspace runs, as seen through the paired desktop. Whether a server is reachable stays + * the desktop's answer; what it supports is the server's own status, read through the desktop. + * `server` is null for the desktop's own workspaces (local, SSH, folder). + */ +export function useWorkspaceServer(args: { + client: RpcClient | null + connState: ConnectionState + desktopCapabilities: readonly string[] + executionHost: ExecutionHostId | undefined +}): { + routeHost: ExecutionHostId | undefined | null + server: { client: RpcClient | null; gates: HostStatusGates } | null +} { + const { client, connState, desktopCapabilities, executionHost } = args + const routeHost = workspaceRouteExecutionHost(client, desktopCapabilities, executionHost) + const serverClient = client && routeHost ? scopeRpcClientToExecutionHost(client, routeHost) : null + // No hostId: the desktop's recorded version and descriptor must not take the server's. + const gates = useHostStatusGates({ hostId: undefined, client: serverClient, connState }) + return { routeHost, server: routeHost === undefined ? null : { client: serverClient, gates } } +} 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 2a3e2132515..3205398fa5c 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 @@ -438,14 +438,11 @@ describe('mobile desktop relay: phone -> desktop -> server', () => { 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) - } + phone.send('settings:targeted', 'settings.get', {}) + phone.send('settings:untargeted', 'settings.get', {}, null) + const targeted = await phone.next('settings:targeted') + expect(isOk(targeted)).toBe(true) + expect(targeted.result).toEqual((await phone.next('settings:untargeted')).result) expect(passthroughOpens.count).toBe(opensBefore) // An execution-host method under the same target still relays. phone.send('list', 'terminal.list', {}) @@ -453,6 +450,20 @@ describe('mobile desktop relay: phone -> desktop -> server', () => { expect(passthroughOpens.count).toBe(opensBefore + 1) }) + it('answers a targeted status from the server, so a server workspace gates on its own features', async () => { + const { host, desktop } = await startTopology() + const phone = await connectPhone(desktop.server, 'iPhone') + const StatusSchema = z.looseObject({ runtimeId: z.string(), capabilities: z.array(z.string()) }) + + phone.send('status:server', 'status.get', {}) + phone.send('status:desktop', 'status.get', {}, null) + const server = StatusSchema.parse((await phone.next('status:server')).result) + const own = StatusSchema.parse((await phone.next('status:desktop')).result) + expect(server.runtimeId).toBe(host.runtime.getRuntimeId()) + expect(own.runtimeId).toBe(desktop.runtime.getRuntimeId()) + expect(server.capabilities).toEqual(host.runtime.getStatus().capabilities) + }) + 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 dec7a3befd4..782075effa9 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 @@ -17,6 +17,10 @@ describe('mobile RPC method routing census', () => { ) }) + it('sends a targeted status to the server, whose features a server workspace gates on', () => { + expect(MOBILE_RPC_METHOD_ROUTES.get('status.get')).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 a4f402dc634..33efcf0c7d3 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,8 @@ export const EXECUTION_HOST_METHODS = [ 'repo.searchRefs', 'repo.sparsePresets', 'repo.update', + // A targeted status is the server's own, so a server workspace gates on what that server supports. + 'status.get', 'session.tabs.activate', 'session.tabs.close', 'session.tabs.closeLifecycle', 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 becea09e246..82097fa21b4 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 @@ -28,7 +28,6 @@ export const PAIRED_DESKTOP_METHODS = [ // The Mac republishes host changes it already observes. 'runtime.clientEvents.subscribe', 'runtime.clientEvents.unsubscribe', - 'status.get', 'diagnostics.memory', 'stats.summary', 'ui.get',