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
This commit is contained in:
Jinwoo-H
2026-10-08 03:42:33 -04:00
parent c679558074
commit ee5fbcd3d6
14 changed files with 216 additions and 153 deletions
@@ -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)
}),
@@ -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', () => ({
@@ -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', () => ({
@@ -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: () => ({
@@ -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,
+103 -36
View File
@@ -7,15 +7,13 @@ import { describe, expect, it, vi } from 'vitest'
const route = vi.hoisted(() => {
const state: {
params: Record<string, string>
hostCapabilities: string[]
statusPending: boolean
client: unknown
seen: (string | undefined)[]
shellRoutes: { params?: Record<string, string> }[]
redirects: { params: Record<string, string | undefined> }[]
} = {
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<ReactTestRenderer> {
async function render(element: ReactElement, gates: HostStatusGates): Promise<ReactTestRenderer> {
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])
})
})
+28 -12
View File
@@ -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 <PageRouteUnavailableScreen hostId={hostId} message={UNREACHABLE_SERVER_MESSAGE} />
}
return (
const screens = (
<WorkspaceExecutionHostContext.Provider value={executionHost}>
{children}
</WorkspaceExecutionHostContext.Provider>
)
return server ? (
<WorkspaceServerClientContext.Provider value={server.client}>
<HostStatusGatesContext.Provider value={server.gates}>
{screens}
</HostStatusGatesContext.Provider>
</WorkspaceServerClientContext.Provider>
) : (
screens
)
}
/** Wraps a workspace route's default export so everything under it knows the workspace's server. */
@@ -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<string, string>,
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)
})
})
+6 -10
View File
@@ -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<RpcClient | null | undefined>(undefined)
/** `useHostClient` for a workspace screen: its calls run where the workspace does. */
export function useWorkspaceClient(hostId: string | undefined): ReturnType<typeof useHostClient> {
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 }
}
+30
View File
@@ -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 } }
}
@@ -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')
@@ -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)
@@ -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',
@@ -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',