From a8a6560a6914aa44eb9ec87e4b741e3a1e2e33e5 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 6 Sep 2026 20:56:00 -0400 Subject: [PATCH] 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. --- .../web-host-session-terminal-actions.test.ts | 115 ++++++++++++++ .../web-host-session-terminal-operations.ts | 92 +++++++---- src/main/runtime/rpc/methods/index.ts | 2 + .../rpc/methods/mobile-web-host-catalog.ts | 6 +- .../mobile-web-terminal-actions.test.ts | 148 ++++++++++++++++++ .../methods/mobile-web-terminal-actions.ts | 94 +++++++++++ .../src/mobile-web-bridge-client.ts | 11 +- .../mobile-web-host-terminal-actions.test.ts | 123 +++++++++++++++ .../src/mobile-web-host-terminal-actions.ts | 76 +++++++++ .../src/mobile-web-terminal-request-client.ts | 37 ++++- .../mobile-web-terminal-request-scheduler.ts | 11 +- 11 files changed, 672 insertions(+), 43 deletions(-) create mode 100644 mobile/src/session/web-host-session-terminal-actions.test.ts create mode 100644 src/main/runtime/rpc/methods/mobile-web-terminal-actions.test.ts create mode 100644 src/main/runtime/rpc/methods/mobile-web-terminal-actions.ts create mode 100644 src/mobile-web/src/mobile-web-host-terminal-actions.test.ts create mode 100644 src/mobile-web/src/mobile-web-host-terminal-actions.ts diff --git a/mobile/src/session/web-host-session-terminal-actions.test.ts b/mobile/src/session/web-host-session-terminal-actions.test.ts new file mode 100644 index 00000000000..4fef38a0098 --- /dev/null +++ b/mobile/src/session/web-host-session-terminal-actions.test.ts @@ -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((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() + }) +}) diff --git a/mobile/src/session/web-host-session-terminal-operations.ts b/mobile/src/session/web-host-session-terminal-operations.ts index adb44ffcece..5714b8c4b9f 100644 --- a/mobile/src/session/web-host-session-terminal-operations.ts +++ b/mobile/src/session/web-host-session-terminal-operations.ts @@ -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) } } } diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index a2f2ddde28c..a3b5e99692b 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts b/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts index 6013fd485de..f676b8846b4 100644 --- a/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts +++ b/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts @@ -13,6 +13,8 @@ const PAGE_METHODS = new Map( '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( { 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 } diff --git a/src/main/runtime/rpc/methods/mobile-web-terminal-actions.test.ts b/src/main/runtime/rpc/methods/mobile-web-terminal-actions.test.ts new file mode 100644 index 00000000000..97e35e603f5 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-terminal-actions.test.ts @@ -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 + })) + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/mobile-web-terminal-actions.ts b/src/main/runtime/rpc/methods/mobile-web-terminal-actions.ts new file mode 100644 index 00000000000..f006b233821 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-terminal-actions.ts @@ -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 { + 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( + 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 } + } + }) +] diff --git a/src/mobile-web/src/mobile-web-bridge-client.ts b/src/mobile-web/src/mobile-web-bridge-client.ts index c41ecc0b400..b7eaaaed82e 100644 --- a/src/mobile-web/src/mobile-web-bridge-client.ts +++ b/src/mobile-web/src/mobile-web-bridge-client.ts @@ -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') => diff --git a/src/mobile-web/src/mobile-web-host-terminal-actions.test.ts b/src/mobile-web/src/mobile-web-host-terminal-actions.test.ts new file mode 100644 index 00000000000..784f660fb5c --- /dev/null +++ b/src/mobile-web/src/mobile-web-host-terminal-actions.test.ts @@ -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]) + }) +}) diff --git a/src/mobile-web/src/mobile-web-host-terminal-actions.ts b/src/mobile-web/src/mobile-web-host-terminal-actions.ts new file mode 100644 index 00000000000..56adb835bdc --- /dev/null +++ b/src/mobile-web/src/mobile-web-host-terminal-actions.ts @@ -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 + +export async function bindMobileWebHostTerminalActions( + requests: MobileWebOneShotRequestClient, + workspaceId: string, + tabId: string, + signal: AbortSignal +): Promise { + 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 + } +} diff --git a/src/mobile-web/src/mobile-web-terminal-request-client.ts b/src/mobile-web/src/mobile-web-terminal-request-client.ts index c9bddb39a14..4562c544d5c 100644 --- a/src/mobile-web/src/mobile-web-terminal-request-client.ts +++ b/src/mobile-web/src/mobile-web-terminal-request-client.ts @@ -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): Promise { return this.requests.request( @@ -33,3 +52,19 @@ export class MobileWebTerminalRequestClient { ) } } + +export function mobileWebTerminalClientBindings( + requests: MobileWebOneShotRequestClient, + features: ReadonlySet +) { + 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) + } +} diff --git a/src/mobile-web/src/mobile-web-terminal-request-scheduler.ts b/src/mobile-web/src/mobile-web-terminal-request-scheduler.ts index a5d16107f22..f724658369e 100644 --- a/src/mobile-web/src/mobile-web-terminal-request-scheduler.ts +++ b/src/mobile-web/src/mobile-web-terminal-request-scheduler.ts @@ -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 { + private async runAction(payload: MobileWebTerminalMetadataRequest): Promise { 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()