feat(mobile-web): move terminal metadata actions to the host

Bind terminal identity before opening its stream and revalidate before clear, rename and display-mode dispatch. Reuse authenticated runtime handlers and preserve pre-dispatch legacy fallback without retrying ambiguous mutations.
This commit is contained in:
Jinwoo-H
2026-09-06 20:56:00 -04:00
parent 9be7d26db9
commit a8a6560a69
11 changed files with 672 additions and 43 deletions
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from 'vitest'
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import type { MobileWebTerminalMetadataAction } from '../../../src/mobile-web/src/mobile-web-host-terminal-actions'
import { webHostSessionTerminalOperations } from './web-host-session-terminal-operations'
function fixture() {
let resolve!: (action: MobileWebTerminalMetadataAction | null) => void
let reject!: (error: Error) => void
const prepared = new Promise<MobileWebTerminalMetadataAction | null>((yes, no) => {
resolve = yes
reject = no
})
const prepareTerminalActions = vi.fn(() => prepared)
const unsubscribe = vi.fn()
const terminalSubscribe = vi.fn(() => ({
streamId: 'S'.repeat(22),
ready: Promise.resolve(),
unsubscribe
}))
const terminalRequest = vi.fn().mockResolvedValue(null)
const client = {
prepareTerminalActions,
terminalSubscribe,
terminalRequest
} as unknown as MobileWebBridgeClient
const operations = webHostSessionTerminalOperations(client)
const onError = vi.fn()
const subscribe = () =>
operations.subscribe(
{
workspaceId: 'workspace',
terminalId: 'tab',
clientId: null,
viewport: null,
visible: true,
capabilities: { terminalBinaryStream: 1 }
},
vi.fn(),
onError
)
const run = vi.fn().mockResolvedValue(null)
return {
resolve,
reject,
prepareTerminalActions,
unsubscribe,
terminalSubscribe,
terminalRequest,
operations,
onError,
subscribe,
run
}
}
describe('hosted terminal metadata lifecycle', () => {
it('binds terminal identity before opening its stream and sends all metadata through that resource', async () => {
const f = fixture()
const cleanup = f.subscribe()
expect(f.terminalSubscribe).not.toHaveBeenCalled()
expect(await f.operations.clear('tab')).toBe(false)
f.resolve(f.run)
await vi.waitFor(() => expect(f.terminalSubscribe).toHaveBeenCalledOnce())
expect(await f.operations.setDisplayMode('tab', 'auto', { cols: 90, rows: 30 }, null)).toBe(
true
)
expect(await f.operations.rename('tab', 'Build')).toBe(true)
expect(await f.operations.clear('tab')).toBe(true)
expect(f.run.mock.calls.map(([request]) => request.operation)).toEqual([
'displayMode',
'rename',
'clear'
])
expect(f.terminalRequest).not.toHaveBeenCalled()
const signal = (f.prepareTerminalActions.mock.calls[0] as unknown[])[2] as AbortSignal
cleanup()
expect(signal.aborted).toBe(true)
expect(f.unsubscribe).toHaveBeenCalledOnce()
expect(await f.operations.clear('tab')).toBe(false)
expect(f.run).toHaveBeenCalledTimes(3)
})
it('retiring a pending binding prevents the eventual stream from opening', async () => {
const f = fixture()
const cleanup = f.subscribe()
cleanup()
f.resolve(f.run)
await Promise.resolve()
expect(f.terminalSubscribe).not.toHaveBeenCalled()
expect(f.onError).not.toHaveBeenCalled()
})
it('uses the legacy stream action when the host lacks catalog grants', async () => {
const f = fixture()
f.subscribe()
f.resolve(null)
await vi.waitFor(() => expect(f.terminalSubscribe).toHaveBeenCalledOnce())
expect(await f.operations.clear('tab')).toBe(true)
expect(f.terminalRequest).toHaveBeenCalledWith({ operation: 'clear', streamId: 'S'.repeat(22) })
})
it('reports binding failures and action ambiguity without opening a fallback stream or repeating the action', async () => {
const failed = fixture()
failed.subscribe()
failed.reject(new Error('connection lost'))
await vi.waitFor(() => expect(failed.onError).toHaveBeenCalledOnce())
expect(failed.terminalSubscribe).not.toHaveBeenCalled()
const f = fixture()
f.subscribe()
f.resolve(f.run)
await vi.waitFor(() => expect(f.terminalSubscribe).toHaveBeenCalledOnce())
f.run.mockRejectedValueOnce(new Error('lost acknowledgement'))
expect(await f.operations.clear('tab')).toBe(false)
expect(f.onError).toHaveBeenCalledOnce()
expect(f.run).toHaveBeenCalledOnce()
expect(f.terminalRequest).not.toHaveBeenCalled()
})
})
@@ -1,3 +1,4 @@
import type { MobileWebTerminalMetadataAction } from '../../../src/mobile-web/src/mobile-web-host-terminal-actions'
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import {
MobileWebTerminalEventState,
@@ -11,7 +12,7 @@ import type {
} from './host-session-terminal-operations'
type WebTerminalStream = {
scheduler: MobileWebTerminalRequestScheduler
scheduler: MobileWebTerminalRequestScheduler | null
unsubscribe: () => void
}
@@ -22,44 +23,71 @@ export function webHostSessionTerminalOperations(
return {
subscribe(args, onEvent, onError) {
streams.get(args.terminalId)?.unsubscribe()
let eventState: MobileWebTerminalEventState
let scheduler: MobileWebTerminalRequestScheduler
const subscription = client.terminalSubscribe(
{
operation: 'subscribe',
workspaceId: args.workspaceId,
tabId: args.terminalId,
viewport: args.viewport ?? { cols: 80, rows: 24 },
visible: args.visible,
...(args.capabilities.mobileInputLeaseOnly === 1 ? { leaseOnly: true as const } : {})
},
(event) => {
const effect = eventState.apply(event)
applyWebTerminalEffect(effect, scheduler, onEvent)
},
onError
)
eventState = new MobileWebTerminalEventState(subscription.streamId)
scheduler = new MobileWebTerminalRequestScheduler(client, subscription.streamId, onError)
const controller = new AbortController()
let unsubscribe = (): void => {}
const stream: WebTerminalStream = {
scheduler,
scheduler: null,
unsubscribe: () => {
if (streams.get(args.terminalId) === stream) {
streams.delete(args.terminalId)
}
scheduler.dispose()
subscription.unsubscribe()
controller.abort()
stream.scheduler?.dispose()
unsubscribe()
}
}
streams.set(args.terminalId, stream)
void subscription.ready.then(
() => scheduler.markBridgeReady(),
() => onError()
const start = (metadataAction: MobileWebTerminalMetadataAction | null) => {
if (controller.signal.aborted) {
return
}
let eventState: MobileWebTerminalEventState
let scheduler: MobileWebTerminalRequestScheduler
const subscription = client.terminalSubscribe(
{
operation: 'subscribe',
workspaceId: args.workspaceId,
tabId: args.terminalId,
viewport: args.viewport ?? { cols: 80, rows: 24 },
visible: args.visible,
...(args.capabilities.mobileInputLeaseOnly === 1 ? { leaseOnly: true as const } : {})
},
(event) => applyWebTerminalEffect(eventState.apply(event), scheduler, onEvent),
onError
)
eventState = new MobileWebTerminalEventState(subscription.streamId)
scheduler = new MobileWebTerminalRequestScheduler(
client,
subscription.streamId,
onError,
metadataAction ?? undefined
)
stream.scheduler = scheduler
unsubscribe = subscription.unsubscribe
void subscription.ready.then(
() => scheduler.markBridgeReady(),
() => onError()
)
}
// Bind before opening the stream so later actions cannot target a replacement terminal.
const prepared = client.prepareTerminalActions?.(
args.workspaceId,
args.terminalId,
controller.signal
)
if (prepared) {
void prepared.then(start).catch(() => {
if (!controller.signal.aborted) {
onError()
}
})
} else {
start(null)
}
return stream.unsubscribe
},
acknowledge(terminalId, throughSequence) {
streams.get(terminalId)?.scheduler.acknowledge(throughSequence)
streams.get(terminalId)?.scheduler?.acknowledge(throughSequence)
},
async sendInput(terminalId, text, enter) {
const scheduler = streams.get(terminalId)?.scheduler
@@ -83,22 +111,22 @@ export function webHostSessionTerminalOperations(
},
setDisplayMode(terminalId, mode, viewport) {
return (
streams.get(terminalId)?.scheduler.setDisplayMode(mode, viewport) ?? Promise.resolve(false)
streams.get(terminalId)?.scheduler?.setDisplayMode(mode, viewport) ?? Promise.resolve(false)
)
},
clear(terminalId) {
return streams.get(terminalId)?.scheduler.clear() ?? Promise.resolve(false)
return streams.get(terminalId)?.scheduler?.clear() ?? Promise.resolve(false)
},
rename(terminalId, title) {
return streams.get(terminalId)?.scheduler.rename(title) ?? Promise.resolve(false)
return streams.get(terminalId)?.scheduler?.rename(title) ?? Promise.resolve(false)
},
pasteClipboard(terminalId, bracketedPaste) {
return (
streams.get(terminalId)?.scheduler.pasteClipboard(bracketedPaste) ?? Promise.resolve(null)
streams.get(terminalId)?.scheduler?.pasteClipboard(bracketedPaste) ?? Promise.resolve(null)
)
},
attachImage(terminalId, source) {
return streams.get(terminalId)?.scheduler.attachImage(source) ?? Promise.resolve(null)
return streams.get(terminalId)?.scheduler?.attachImage(source) ?? Promise.resolve(null)
}
}
}
+2
View File
@@ -1,3 +1,4 @@
import { MOBILE_WEB_TERMINAL_ACTION_METHODS } from './mobile-web-terminal-actions'
import { MOBILE_WEB_NATIVE_CHAT_STREAM_METHOD } from './mobile-web-native-chat-stream'
import { MOBILE_WEB_NATIVE_CHAT_METHODS } from './mobile-web-native-chat'
import type { RpcAnyMethod } from '../core'
@@ -109,6 +110,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
MOBILE_WEB_HOST_CATALOG_METHOD,
...MOBILE_WEB_FILE_READ_METHODS,
MOBILE_WEB_FILE_WATCH_METHOD,
...MOBILE_WEB_TERMINAL_ACTION_METHODS,
...MOBILE_WEB_NATIVE_CHAT_METHODS,
MOBILE_WEB_NATIVE_CHAT_STREAM_METHOD,
...MOBILE_WEB_PACKAGE_METHODS
@@ -13,6 +13,8 @@ const PAGE_METHODS = new Map<string, MobileWebHostGrant>(
'files.readChunk',
'mobileWeb.files.searchPaths',
'mobileWeb.files.read',
'mobileWeb.terminal.bind',
'mobileWeb.terminal.action',
'mobileWeb.nativeChat.bind',
'mobileWeb.nativeChat.read',
'mobileWeb.nativeChat.mutate'
@@ -21,7 +23,9 @@ const PAGE_METHODS = new Map<string, MobileWebHostGrant>(
{
method,
workspaceParam: 'worktree',
...(method.startsWith('mobileWeb.nativeChat.') ? { pageSessionParam: 'pageSession' } : {}),
...(method.startsWith('mobileWeb.nativeChat.') || method.startsWith('mobileWeb.terminal.')
? { pageSessionParam: 'pageSession' }
: {}),
maxRequestBytes: method === 'mobileWeb.nativeChat.mutate' ? 600 * 1024 : 16 * 1024,
maxResponseBytes: 512 * 1024
}
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcContext } from '../core'
import { MOBILE_WEB_TERMINAL_ACTION_METHODS } from './mobile-web-terminal-actions'
import { MOBILE_WEB_HOST_CATALOG_METHOD } from './mobile-web-host-catalog'
const [bind, action] = MOBILE_WEB_TERMINAL_ACTION_METHODS
function fixture(worktree = 'folder:workspace') {
const tab = { id: 'tab', type: 'terminal', status: 'ready', terminal: 'private-terminal' }
const runtime = {
listMobileSessionTabs: vi.fn().mockResolvedValue({ worktree, tabs: [tab] }),
registerSubscriptionCleanup: vi.fn(),
renameTerminal: vi.fn().mockResolvedValue({ handle: 'private-terminal' }),
clearTerminalBuffer: vi.fn().mockResolvedValue({ handle: 'private-terminal' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'remote-pty' }),
updateMobileSubscriberViewport: vi.fn(),
markMobileActor: vi.fn(),
setMobileDisplayMode: vi.fn(),
applyMobileDisplayMode: vi.fn(),
getLayout: vi.fn()
}
const context = {
runtime,
connectionId: 'connection',
clientId: 'authenticated',
pairedDeviceId: 'device'
} as unknown as RpcContext
const scope = { worktree: `id:${worktree}`, pageSession: 'page', timeoutMs: 15_000 }
async function bound() {
return (await bind.handler({ ...scope, tabId: 'tab' }, context)) as { resourceId: string }
}
return { context, runtime, scope, tab, bound }
}
afterEach(() => vi.useRealTimers())
describe('host-owned terminal metadata', () => {
it.each(['folder:workspace', 'ssh-workspace'])(
'binds %s through the owning runtime without exposing terminal handles',
async (workspace) => {
const f = fixture(workspace)
const resource = await f.bound()
expect(resource.resourceId).toMatch(/^resource_/)
expect(f.runtime.listMobileSessionTabs).toHaveBeenCalledWith(f.scope.worktree, 'device')
for (const method of ['terminal.rename', 'terminal.clearBuffer']) {
expect(
await action.handler(
{ ...f.scope, ...resource, method, fields: { title: 'Build', terminal: 'forged' } },
f.context
)
).toEqual({ applied: true })
}
expect(f.runtime.renameTerminal).toHaveBeenCalledWith('private-terminal', 'Build')
expect(f.runtime.clearTerminalBuffer).toHaveBeenCalledWith('private-terminal')
}
)
it('uses the authenticated mobile actor and existing viewport driver', async () => {
const f = fixture()
const resource = await f.bound()
await action.handler(
{
...f.scope,
...resource,
method: 'terminal.setDisplayMode',
fields: { mode: 'auto', viewport: { cols: 90, rows: 30 }, client: { id: 'forged' } }
},
f.context
)
expect(f.runtime.updateMobileSubscriberViewport).toHaveBeenCalledWith(
'remote-pty',
'authenticated',
{ cols: 90, rows: 30 }
)
expect(f.runtime.markMobileActor).toHaveBeenCalledWith('remote-pty', 'authenticated')
expect(f.runtime.applyMobileDisplayMode).toHaveBeenCalledWith('remote-pty')
})
it('refuses replaced, missing, nonready and cross-scope terminal bindings before mutation', async () => {
const f = fixture()
const resource = await f.bound()
const params = { ...f.scope, ...resource, method: 'terminal.clearBuffer', fields: {} }
for (const tabs of [
[{ ...f.tab, terminal: 'replacement' }],
[],
[{ ...f.tab, status: 'pending-handle' }]
]) {
f.runtime.listMobileSessionTabs.mockResolvedValue({ worktree: 'folder:workspace', tabs })
await expect(action.handler(params, f.context)).rejects.toThrow('selector_not_found')
}
for (const changed of [{ pageSession: 'other' }, { worktree: 'id:other' }]) {
await expect(action.handler({ ...params, ...changed }, f.context)).rejects.toThrow(
'selector_not_found'
)
}
await expect(action.handler(params, { ...f.context, connectionId: 'other' })).rejects.toThrow(
'selector_not_found'
)
expect(f.runtime.clearTerminalBuffer).not.toHaveBeenCalled()
})
it('does not dispatch after disconnect during identity lookup, and never retries handler failures', async () => {
const f = fixture()
const resource = await f.bound()
const params = { ...f.scope, ...resource, method: 'terminal.clearBuffer', fields: {} }
const controller = new AbortController()
f.runtime.listMobileSessionTabs.mockImplementationOnce(async () => {
controller.abort()
return { worktree: 'folder:workspace', tabs: [f.tab] }
})
await expect(
action.handler(params, { ...f.context, signal: controller.signal })
).rejects.toThrow('runtime_unavailable')
expect(f.runtime.clearTerminalBuffer).not.toHaveBeenCalled()
f.runtime.clearTerminalBuffer.mockRejectedValueOnce(new Error('lost acknowledgement'))
await expect(action.handler(params, f.context)).rejects.toThrow('lost acknowledgement')
expect(f.runtime.clearTerminalBuffer).toHaveBeenCalledOnce()
})
it('expires a delayed identity lookup without changing the terminal', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const f = fixture()
const resource = await f.bound()
f.runtime.listMobileSessionTabs.mockImplementationOnce(async () => {
vi.setSystemTime(20_000)
return { worktree: 'folder:workspace', tabs: [f.tab] }
})
await expect(
action.handler(
{ ...f.scope, ...resource, method: 'terminal.clearBuffer', fields: {} },
f.context
)
).rejects.toThrow('runtime_unavailable')
expect(f.runtime.clearTerminalBuffer).not.toHaveBeenCalled()
})
it('advertises both methods with host page-session authority', async () => {
expect(
await MOBILE_WEB_HOST_CATALOG_METHOD.handler(
{ methods: MOBILE_WEB_TERMINAL_ACTION_METHODS.map((method) => method.name) },
{} as RpcContext
)
).toEqual({
grants: MOBILE_WEB_TERMINAL_ACTION_METHODS.map((method) => ({
method: method.name,
workspaceParam: 'worktree',
pageSessionParam: 'pageSession',
maxRequestBytes: 16 * 1024,
maxResponseBytes: 512 * 1024
}))
})
})
})
@@ -0,0 +1,94 @@
import { z } from 'zod'
import { defineMethod, isStreamingMethod, type RpcContext } from '../core'
import { TERMINAL_QUERY_METHODS } from './terminal/terminal-query-methods'
import { TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS } from './terminal/terminal-viewport-methods'
import {
registerMobileWebPageResource,
resolveMobileWebPageResource
} from './mobile-web-page-resources'
const Scope = z.object({
worktree: z.string().min(1).max(4096),
pageSession: z.string().min(1).max(160)
})
const Tab = z.object({
id: z.string(),
type: z.literal('terminal'),
status: z.literal('ready'),
terminal: z.string().min(1).max(256)
})
type Binding = { tabId: string; terminal: string; worktreeId: string }
const actions = new Map(
[...TERMINAL_QUERY_METHODS, ...TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS]
.filter((method) =>
['terminal.setDisplayMode', 'terminal.clearBuffer', 'terminal.rename'].includes(method.name)
)
.map((method) => [method.name, method])
)
async function readBinding(context: RpcContext, worktree: string, tabId: string): Promise<Binding> {
const snapshot = await context.runtime.listMobileSessionTabs(worktree, context.pairedDeviceId)
const parsed = Tab.safeParse(snapshot.tabs.find((tab) => tab.id === tabId))
if (!parsed.success) {
throw new Error('selector_not_found')
}
return { tabId, terminal: parsed.data.terminal, worktreeId: snapshot.worktree }
}
export const MOBILE_WEB_TERMINAL_ACTION_METHODS = [
defineMethod({
name: 'mobileWeb.terminal.bind',
params: Scope.extend({ tabId: z.string().min(1).max(512) }),
handler: async (params, context) => {
const binding = await readBinding(context, params.worktree, params.tabId)
return {
resourceId: registerMobileWebPageResource(context, params.pageSession, {
kind: 'terminal',
workspace: params.worktree,
identity: JSON.stringify(binding),
value: binding
})
}
}
}),
defineMethod({
name: 'mobileWeb.terminal.action',
params: Scope.extend({
resourceId: z.string().min(1).max(160),
timeoutMs: z.number().int().min(1).max(15_000),
method: z.enum(['terminal.setDisplayMode', 'terminal.clearBuffer', 'terminal.rename']),
fields: z.record(z.string(), z.unknown())
}),
handler: async (params, context) => {
const deadline = Date.now() + params.timeoutMs
if (!context.clientId || context.signal?.aborted) {
throw new Error('runtime_unavailable')
}
const binding = resolveMobileWebPageResource<Binding>(
context,
params.pageSession,
params.worktree,
'terminal',
params.resourceId
)
const current = await readBinding(context, params.worktree, binding.tabId)
if (JSON.stringify(current) !== JSON.stringify(binding)) {
throw new Error('selector_not_found')
}
const action = actions.get(params.method)
if (!action || isStreamingMethod(action)) {
throw new Error('method_not_found')
}
const input = action.params!.parse({
...params.fields,
terminal: binding.terminal,
client: { id: context.clientId, type: 'mobile' }
})
if (context.signal?.aborted || Date.now() >= deadline) {
throw new Error('runtime_unavailable')
}
await action.handler(input, context)
return { applied: true }
}
})
]
@@ -53,7 +53,7 @@ import type { MobileWebSourceControlReviewRequestClient } from './mobile-web-sou
import { MobileWebSourceControlSyncRequestClient } from './mobile-web-source-control-sync-request-client'
import { MobileWebSpeechRequestClient } from './mobile-web-speech-request-client'
import { MobileWebTaskRequestClient } from './mobile-web-task-request-client'
import { MobileWebTerminalRequestClient } from './mobile-web-terminal-request-client'
import * as terminal from './mobile-web-terminal-request-client'
import { mobileWebWorkspaceClientBindings } from './mobile-web-workspace-client-bindings'
import { MobileWebWorkspaceRequestClient } from './mobile-web-workspace-request-client'
import { MobileWebWorkspaceCreationCreateRequestClient } from './mobile-web-workspace-creation-create-request-client'
@@ -146,8 +146,9 @@ export class MobileWebBridgeClient {
readonly agentHistory: MobileWebAgentHistoryRequestClient
readonly speech: MobileWebSpeechRequestClient
readonly task: MobileWebTaskRequestClient
readonly terminalRequest!: MobileWebTerminalRequestClient['request']
readonly terminalDeviceInputRequest!: MobileWebTerminalRequestClient['deviceInput']
readonly prepareTerminalActions!: terminal.MobileWebTerminalRequestClient['prepareActions']
readonly terminalRequest!: terminal.MobileWebTerminalRequestClient['request']
readonly terminalDeviceInputRequest!: terminal.MobileWebTerminalRequestClient['deviceInput']
readonly browserNavigate!: MobileWebBrowserRequestClient['navigate']
readonly browserPointer!: MobileWebBrowserRequestClient['pointer']
readonly browserKeyboard!: MobileWebBrowserRequestClient['keyboard']
@@ -208,9 +209,7 @@ export class MobileWebBridgeClient {
)
this.native = new MobileWebNativeRequestClient(this.requests)
this.markdown = new MobileWebMarkdownRequestClient(this.requests)
const terminalRequests = new MobileWebTerminalRequestClient(this.requests)
this.terminalRequest = terminalRequests.request.bind(terminalRequests)
this.terminalDeviceInputRequest = terminalRequests.deviceInput.bind(terminalRequests)
Object.assign(this, terminal.mobileWebTerminalClientBindings(this.requests, this.shellFeatures))
Object.assign(this, mobileWebBrowserNavigationClientBindings(this.requests))
this.subscriptions = new MobileWebBridgeSubscriptionClient({
getGrant: (capability, operation = 'subscribe') =>
@@ -0,0 +1,123 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindMobileWebHostTerminalActions } from './mobile-web-host-terminal-actions'
import {
MobileWebTerminalRequestClient,
mobileWebTerminalClientBindings
} from './mobile-web-terminal-request-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
const methods = ['mobileWeb.terminal.bind', 'mobileWeb.terminal.action']
function fixture() {
const request = vi.fn(async (_capability, operation, payload) => {
if (operation === 'hostCatalog') {
return { grants: methods.map((method) => ({ method })) }
}
return payload.method === methods[0]
? { resourceId: 'resource-terminal' }
: { applied: true, future: 1 }
})
const requests = { supports: () => true, request } as unknown as MobileWebOneShotRequestClient
const signal = new AbortController().signal
return { requests, request, signal }
}
afterEach(() => vi.useRealTimers())
describe('page-owned terminal metadata forwarding', () => {
it('uses one bound resource for all metadata actions, keeping stream ids out of host payloads', async () => {
const f = fixture()
const run = await bindMobileWebHostTerminalActions(f.requests, 'workspace', 'tab', f.signal)
for (const request of [
{
operation: 'displayMode' as const,
streamId: 'stream',
mode: 'auto' as const,
viewport: { cols: 90, rows: 30 }
},
{ operation: 'rename' as const, streamId: 'stream', title: 'Build' },
{ operation: 'clear' as const, streamId: 'stream' }
]) {
await expect(run!(request)).resolves.toBeNull()
}
expect(f.request.mock.calls.map((call) => call[2])).toEqual([
{ methods },
{ method: methods[0], workspaceId: 'workspace', params: { tabId: 'tab' } },
...[
{
method: 'terminal.setDisplayMode',
fields: { mode: 'auto', viewport: { cols: 90, rows: 30 } }
},
{ method: 'terminal.rename', fields: { title: 'Build' } },
{ method: 'terminal.clearBuffer', fields: {} }
].map((params) => ({
method: methods[1],
workspaceId: 'workspace',
params: { resourceId: 'resource-terminal', ...params, timeoutMs: 15_000 }
}))
])
expect(
f.request.mock.calls.every(
(call) =>
(call as unknown[]).at(-1) &&
((call as unknown[]).at(-1) as { signal: AbortSignal }).signal === f.signal
)
).toBe(true)
})
it('requires both page-session and dispatch shell features before touching the generic lane', async () => {
const f = fixture()
for (const features of [
[],
['workspace.hostPageSession.v1'],
['workspace.hostRequestDispatch.v1']
]) {
const client = mobileWebTerminalClientBindings(f.requests, new Set(features))
expect(client.prepareTerminalActions('w', 't', f.signal)).toBeNull()
}
expect(f.request).not.toHaveBeenCalled()
const client = mobileWebTerminalClientBindings(
f.requests,
new Set(['workspace.hostPageSession.v1', 'workspace.hostRequestDispatch.v1'])
)
expect(await client.prepareTerminalActions('w', 't', f.signal)).toBeTypeOf('function')
})
it('uses legacy only before action dispatch on old shells or missing host grants', async () => {
const f = fixture()
expect(
new MobileWebTerminalRequestClient(f.requests).prepareActions('w', 't', f.signal)
).toBeNull()
expect(f.request).not.toHaveBeenCalled()
f.request.mockResolvedValueOnce({ grants: [] } as never)
await expect(
bindMobileWebHostTerminalActions(f.requests, 'w', 't', f.signal)
).resolves.toBeNull()
expect(f.request).toHaveBeenCalledOnce()
f.request.mockRejectedValueOnce(new MobileWebBridgeClientError('unsupported_capability', false))
await expect(
bindMobileWebHostTerminalActions(f.requests, 'w', 't', f.signal)
).resolves.toBeNull()
})
it.each(['timeout', 'unsupported_capability', 'host_error'] as const)(
'does not retry after action returns %s',
async (code) => {
const f = fixture()
const run = await bindMobileWebHostTerminalActions(f.requests, 'w', 't', f.signal)
f.request.mockRejectedValueOnce(new MobileWebBridgeClientError(code, true))
await expect(run!({ operation: 'clear', streamId: 'stream' })).rejects.toMatchObject({ code })
expect(f.request).toHaveBeenCalledTimes(3)
}
)
it('spends one binding budget across catalog and host lookup', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const f = fixture()
f.request.mockImplementationOnce(async () => {
vi.setSystemTime(4_000)
return { grants: methods.map((method) => ({ method })) } as never
})
await bindMobileWebHostTerminalActions(f.requests, 'w', 't', f.signal)
expect(
f.request.mock.calls.map(
(call) => ((call as unknown[]).at(-1) as { timeoutMs: number }).timeoutMs
)
).toEqual([15_000, 12_000])
})
})
@@ -0,0 +1,76 @@
import type { MobileWebTerminalRequest } from '../../shared/mobile-web/terminal-stream-contract'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { readMobileWebHostMethods, requestMobileWebHost } from './mobile-web-host-request-client'
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
export type MobileWebTerminalMetadataRequest = Extract<
MobileWebTerminalRequest,
{ operation: 'displayMode' | 'clear' | 'rename' }
>
export type MobileWebTerminalMetadataAction = (
request: MobileWebTerminalMetadataRequest
) => Promise<null>
export async function bindMobileWebHostTerminalActions(
requests: MobileWebOneShotRequestClient,
workspaceId: string,
tabId: string,
signal: AbortSignal
): Promise<MobileWebTerminalMetadataAction | null> {
const methods = ['mobileWeb.terminal.bind', 'mobileWeb.terminal.action']
const deadline = Date.now() + 15_000
const options = () => {
const timeoutMs = deadline - Date.now()
if (timeoutMs <= 0) {
throw new MobileWebBridgeClientError('timeout', true)
}
return { signal, timeoutMs }
}
let bound: unknown
try {
const catalog = await readMobileWebHostMethods(requests, methods, options())
if (!methods.every((method) => catalog.grants.some((grant) => grant.method === method))) {
return null
}
bound = await requestMobileWebHost(requests, methods[0], workspaceId, { tabId }, options())
} catch (error) {
if (error instanceof MobileWebBridgeClientError && error.code === 'unsupported_capability') {
return null
}
throw error
}
if (
typeof bound !== 'object' ||
bound === null ||
!('resourceId' in bound) ||
typeof bound.resourceId !== 'string'
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
const resourceId = bound.resourceId
return async ({ operation, streamId: _streamId, ...fields }) => {
const method =
operation === 'displayMode'
? 'terminal.setDisplayMode'
: operation === 'clear'
? 'terminal.clearBuffer'
: 'terminal.rename'
// A missing acknowledgement may hide a committed action; never retry on the legacy lane.
const result = await requestMobileWebHost(
requests,
methods[1],
workspaceId,
{ resourceId, method, fields, timeoutMs: 15_000 },
{ signal }
)
if (
typeof result !== 'object' ||
result === null ||
!('applied' in result) ||
result.applied !== true
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return null
}
}
@@ -1,3 +1,8 @@
import {
MOBILE_WEB_SHELL_HOST_PAGE_SESSION_FEATURE,
MOBILE_WEB_SHELL_HOST_REQUEST_DISPATCH_FEATURE
} from '../../shared/mobile-web/shell-feature-contract'
import { bindMobileWebHostTerminalActions } from './mobile-web-host-terminal-actions'
import { MobileWebHapticSelectionResultSchema } from '../../shared/mobile-web/bridge-operation-contract'
import {
MobileWebTerminalDeviceInputResultSchema,
@@ -9,7 +14,21 @@ import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-reques
// One-shot terminal operations; the stream itself lives on the subscription client.
export class MobileWebTerminalRequestClient {
constructor(private readonly requests: MobileWebOneShotRequestClient) {}
constructor(
private readonly requests: MobileWebOneShotRequestClient,
private readonly hostActions = false
) {}
prepareActions(workspaceId: string, tabId: string, signal: AbortSignal) {
if (
!this.hostActions ||
!this.requests.supports('workspace', 'hostCatalog') ||
!this.requests.supports('workspace', 'hostRequest')
) {
return null
}
return bindMobileWebHostTerminalActions(this.requests, workspaceId, tabId, signal)
}
request(payload: Exclude<MobileWebTerminalRequest, { operation: 'subscribe' }>): Promise<null> {
return this.requests.request(
@@ -33,3 +52,19 @@ export class MobileWebTerminalRequestClient {
)
}
}
export function mobileWebTerminalClientBindings(
requests: MobileWebOneShotRequestClient,
features: ReadonlySet<string>
) {
const client = new MobileWebTerminalRequestClient(
requests,
features.has(MOBILE_WEB_SHELL_HOST_PAGE_SESSION_FEATURE) &&
features.has(MOBILE_WEB_SHELL_HOST_REQUEST_DISPATCH_FEATURE)
)
return {
prepareTerminalActions: client.prepareActions.bind(client),
terminalRequest: client.request.bind(client),
terminalDeviceInputRequest: client.deviceInput.bind(client)
}
}
@@ -1,3 +1,7 @@
import type {
MobileWebTerminalMetadataAction,
MobileWebTerminalMetadataRequest
} from './mobile-web-host-terminal-actions'
import type {
MobileWebTerminalDeviceInputResult,
MobileWebTerminalRequest
@@ -33,7 +37,8 @@ export class MobileWebTerminalRequestScheduler {
constructor(
private readonly client: MobileWebBridgeClient,
private readonly streamId: string,
private readonly onError: () => void
private readonly onError: () => void,
private readonly metadataAction?: MobileWebTerminalMetadataAction
) {}
markBridgeReady(): void {
@@ -239,12 +244,12 @@ export class MobileWebTerminalRequestScheduler {
return this.client.terminalRequest(payload)
}
private async runAction(payload: OrdinaryTerminalRequest): Promise<boolean> {
private async runAction(payload: MobileWebTerminalMetadataRequest): Promise<boolean> {
if (this.disposed || !this.bridgeReady) {
return false
}
try {
await this.request(payload)
await (this.metadataAction ? this.metadataAction(payload) : this.request(payload))
return true
} catch {
this.reportError()