mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user