diff --git a/mobile/src/mobile-web/mobile-web-terminal-input.ts b/mobile/src/mobile-web/mobile-web-terminal-input.ts index f619ab6fe31..f76c520a2e7 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-input.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-input.ts @@ -29,14 +29,18 @@ export function handleMobileWebTerminalInput(args: { requireLive(args.isLive) if (args.request.operation === 'input' || args.request.operation === 'queryReply') { const bytes = atob(args.request.data) - // Why: the page's own label is not evidence; the host drops opcode 18 that fails this same grammar check. - if (args.request.operation === 'queryReply' && !isTerminalQueryReply(bytes)) { - return null + if (args.request.operation === 'queryReply') { + // Why: the page's own label is not evidence; the host drops opcode 18 that fails this same grammar check. + // A host that never echoed the queryReply capability would take the reply as floor-taking + // shell input, so drop rather than downgrade to opcode 0 (TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY). + if (!isTerminalQueryReply(bytes) || !args.record.supportsQueryReply) { + return null + } } sendMobileWebTerminalFrame( args.client, args.record, - args.request.operation === 'queryReply' && args.record.supportsQueryReply + args.request.operation === 'queryReply' ? TerminalStreamOpcode.QueryReply : TerminalStreamOpcode.Input, Uint8Array.from(bytes, (character) => character.charCodeAt(0)) diff --git a/mobile/src/mobile-web/mobile-web-terminal-lease-streams.ts b/mobile/src/mobile-web/mobile-web-terminal-lease-streams.ts index 38e8e50cb2c..95ffc34ab36 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-lease-streams.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-lease-streams.ts @@ -149,11 +149,9 @@ export class MobileWebTerminalLeaseStreams { streamId: record.pageStreamId, viewport: record.viewport, startSequence: 0, + // Why: a lease stream negotiates no output multiplex, so it carries no reply opcode. maxOutstandingBytes: MOBILE_WEB_TERMINAL_MAX_OUTSTANDING_BYTES, - // Constants for the same reason as the multiplex path: the host publishes no floor state and - // no reply-authority verdict over the terminal stream, so neither can be derived here. - inputFloor: 'held', - queryReplyAuthority: true + queryReplyNegotiated: false }) return } diff --git a/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts b/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts index fe676d7a833..bed0816cf5e 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts @@ -37,11 +37,7 @@ export function handleMobileWebTerminalMultiplexEvent(args: { viewport: record.viewport, startSequence: record.sentSequence, maxOutstandingBytes: MOBILE_WEB_TERMINAL_MAX_OUTSTANDING_BYTES, - // Constants: the host publishes neither over the terminal stream. `isMobileTerminalQueryReplyAuthority` - // elects one subscriber but is never sent, and opcode-17 WriteUnavailable reports a single - // refused write with no regain signal, so it is not the floor state this field declares. - inputFloor: 'held', - queryReplyAuthority: true + queryReplyNegotiated: record.supportsQueryReply }) } return false diff --git a/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts b/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts index 43ede100bc7..563d7aa08ec 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts @@ -270,6 +270,41 @@ describe('MobileWebTerminalStreams', () => { expect(decodeTerminalStreamText(harness.sentFrames.at(-1)!.payload)).toBe('\x1b[0n') }) + it('tells the page a host that never echoed queryReply negotiated no reply opcode', async () => { + const harness = createHarness() + await harness.streams.start({ + requestId: 'request-old-host', + subscriptionId: SUBSCRIPTION_ID, + payload: subscribePayload(), + client: harness.client, + isRequestActive: () => true + }) + harness.emitMultiplex({ type: 'ready' }) + const subscribe = harness.sentFrames.at(-1)! + const hostStreamId = decodeTerminalStreamJson>(subscribe.payload)! + .streamId as number + harness.emitMultiplex({ type: 'subscribed', streamId: hostStreamId }) + await settle() + + expect(harness.events.at(-1)?.event).toMatchObject({ + type: 'subscribed', + queryReplyNegotiated: false + }) + const framesBefore = harness.sentFrames.length + await harness.streams.handle( + { + operation: 'queryReply', + streamId: SUBSCRIPTION_ID, + sequence: 0, + data: Buffer.from('\x1b[0n').toString('base64') + }, + harness.client + ) + + // Why: opcode 0 would land the reply as floor-taking shell input on that host. + expect(harness.sentFrames).toHaveLength(framesBefore) + }) + it('drops a page query-reply that is not a reply grammar', async () => { const harness = createHarness() await harness.streams.start({ @@ -303,35 +338,6 @@ describe('MobileWebTerminalStreams', () => { expect(harness.sentFrames.length).toBe(framesBefore) }) - it('falls back to legacy input when an older host omits query-reply support', async () => { - const harness = createHarness() - await harness.streams.start({ - requestId: 'request-legacy-query', - subscriptionId: SUBSCRIPTION_ID, - payload: subscribePayload(), - client: harness.client, - isRequestActive: () => true - }) - harness.emitMultiplex({ type: 'ready' }) - const subscribe = harness.sentFrames.at(-1)! - const hostStreamId = decodeTerminalStreamJson>(subscribe.payload)! - .streamId as number - harness.emitMultiplex({ type: 'subscribed', streamId: hostStreamId }) - - await harness.streams.handle( - { - operation: 'queryReply', - streamId: SUBSCRIPTION_ID, - sequence: 0, - data: Buffer.from('\x1b[0n').toString('base64') - }, - harness.client - ) - - expect(harness.sentFrames.at(-1)?.opcode).toBe(TerminalStreamOpcode.Input) - expect(decodeTerminalStreamText(harness.sentFrames.at(-1)!.payload)).toBe('\x1b[0n') - }) - it('keeps shell-owned device input native and returns status without native paths', async () => { const harness = createHarness() await harness.streams.start({ @@ -475,8 +481,7 @@ describe('MobileWebTerminalStreams', () => { viewport: { cols: 80, rows: 24 }, startSequence: 0, maxOutstandingBytes: 256 * 1024, - inputFloor: 'held', - queryReplyAuthority: true + queryReplyNegotiated: false } } ]) diff --git a/mobile/src/session/host-session-terminal-operations.ts b/mobile/src/session/host-session-terminal-operations.ts index 001da88f87a..0fce6b0a8e9 100644 --- a/mobile/src/session/host-session-terminal-operations.ts +++ b/mobile/src/session/host-session-terminal-operations.ts @@ -7,8 +7,6 @@ export type HostSessionTerminalStreamEvent = type: 'subscribed' cols?: number rows?: number - inputFloor?: 'held' | 'read-only' - queryReplyAuthority?: boolean } | { type: 'scrollback' @@ -40,8 +38,6 @@ export type HostSessionTerminalStreamEvent = | { type: 'metadata' displayMode?: 'auto' | 'desktop' | 'phone' - inputFloor?: 'held' | 'read-only' - queryReplyAuthority?: boolean cwd?: string } | { type: 'end' | 'error' } diff --git a/mobile/src/session/web-host-session-terminal-operations.test.ts b/mobile/src/session/web-host-session-terminal-operations.test.ts index 607ddfca7b6..8026906d9db 100644 --- a/mobile/src/session/web-host-session-terminal-operations.test.ts +++ b/mobile/src/session/web-host-session-terminal-operations.test.ts @@ -232,7 +232,6 @@ function subscribed(): MobileWebTerminalEvent { viewport: { cols: 90, rows: 30 }, startSequence: 0, maxOutstandingBytes: 256 * 1024, - inputFloor: 'held', - queryReplyAuthority: true + queryReplyNegotiated: true } } diff --git a/mobile/src/session/web-host-session-terminal-operations.ts b/mobile/src/session/web-host-session-terminal-operations.ts index aaf8090f2a1..adb44ffcece 100644 --- a/mobile/src/session/web-host-session-terminal-operations.ts +++ b/mobile/src/session/web-host-session-terminal-operations.ts @@ -109,22 +109,14 @@ function applyWebTerminalEffect( onEvent: (event: HostSessionTerminalStreamEvent) => void ): void { if (effect.type === 'ready') { - scheduler.markHostReady(effect.inputFloor, effect.queryReplyAuthority) + scheduler.markHostReady(effect.queryReplyNegotiated) onEvent({ type: 'subscribed', cols: effect.viewport.cols, - rows: effect.viewport.rows, - inputFloor: effect.inputFloor, - queryReplyAuthority: effect.queryReplyAuthority - }) - } else if (effect.type === 'authority') { - scheduler.setAuthority(effect.inputFloor, effect.queryReplyAuthority) - onEvent({ - type: 'metadata', - displayMode: effect.displayMode, - inputFloor: effect.inputFloor, - queryReplyAuthority: effect.queryReplyAuthority + rows: effect.viewport.rows }) + } else if (effect.type === 'displayMode') { + onEvent({ type: 'metadata', displayMode: effect.displayMode }) } else if (effect.type === 'write') { onEvent({ type: 'data', diff --git a/src/mobile-web/src/mobile-web-bridge-post-dispose.test.ts b/src/mobile-web/src/mobile-web-bridge-post-dispose.test.ts index 85589d23e92..49bc0b819be 100644 --- a/src/mobile-web/src/mobile-web-bridge-post-dispose.test.ts +++ b/src/mobile-web/src/mobile-web-bridge-post-dispose.test.ts @@ -81,7 +81,7 @@ describe('after dispose', () => { terminalDeviceInputRequest: vi.fn() } as unknown as MobileWebBridgeClientType const scheduler = new MobileWebTerminalRequestScheduler(client, 'T'.repeat(22), onError) - scheduler.markHostReady('held', true) + scheduler.markHostReady(true) const inFlight = scheduler.sendInputAsync('input', 'YQ==') await vi.waitFor(() => expect(terminalRequest).toHaveBeenCalledTimes(1)) diff --git a/src/mobile-web/src/mobile-web-terminal-bridge-client.test.ts b/src/mobile-web/src/mobile-web-terminal-bridge-client.test.ts index 04f36863da1..c12b103f99c 100644 --- a/src/mobile-web/src/mobile-web-terminal-bridge-client.test.ts +++ b/src/mobile-web/src/mobile-web-terminal-bridge-client.test.ts @@ -61,8 +61,7 @@ it('subscribes by workspace/tab IDs and sends typed terminal ACKs', async () => viewport: { cols: 80, rows: 24 }, startSequence: 0, maxOutstandingBytes: 256 * 1024, - inputFloor: 'held', - queryReplyAuthority: true + queryReplyNegotiated: true } }) expect(onEvent).toHaveBeenCalledOnce() diff --git a/src/mobile-web/src/mobile-web-terminal-event-state.test.ts b/src/mobile-web/src/mobile-web-terminal-event-state.test.ts index 064c3964235..3aebde95ae2 100644 --- a/src/mobile-web/src/mobile-web-terminal-event-state.test.ts +++ b/src/mobile-web/src/mobile-web-terminal-event-state.test.ts @@ -18,15 +18,13 @@ describe('MobileWebTerminalEventState', () => { viewport: { cols: 80, rows: 24 }, startSequence: 10, maxOutstandingBytes: 256 * 1024, - inputFloor: 'held', - queryReplyAuthority: true + queryReplyNegotiated: true }) ).toEqual({ type: 'ready', sequence: 10, viewport: { cols: 80, rows: 24 }, - inputFloor: 'held', - queryReplyAuthority: true + queryReplyNegotiated: true }) state.apply({ type: 'snapshotStart', @@ -86,8 +84,7 @@ describe('MobileWebTerminalEventState', () => { viewport: { cols: 80, rows: 24 }, startSequence: 4, maxOutstandingBytes: 256 * 1024, - inputFloor: 'held', - queryReplyAuthority: true + queryReplyNegotiated: true }) expect( state.apply({ @@ -100,21 +97,29 @@ describe('MobileWebTerminalEventState', () => { ).toEqual({ type: 'resync', fromSequence: 4, reason: 'gap' }) }) - it('publishes input authority changes without exposing host metadata', () => { + it('publishes display mode changes without exposing host metadata', () => { + const state = new MobileWebTerminalEventState(STREAM_ID) + expect(state.apply({ type: 'metadata', streamId: STREAM_ID, displayMode: 'desktop' })).toEqual({ + type: 'displayMode', + displayMode: 'desktop' + }) + }) + + it('reads a shell that omits queryReplyNegotiated as no negotiated reply opcode', () => { const state = new MobileWebTerminalEventState(STREAM_ID) expect( state.apply({ - type: 'metadata', + type: 'subscribed', streamId: STREAM_ID, - displayMode: 'desktop', - inputFloor: 'read-only', - queryReplyAuthority: false + viewport: { cols: 80, rows: 24 }, + startSequence: 0, + maxOutstandingBytes: 256 * 1024 }) ).toEqual({ - type: 'authority', - displayMode: 'desktop', - inputFloor: 'read-only', - queryReplyAuthority: false + type: 'ready', + sequence: 0, + viewport: { cols: 80, rows: 24 }, + queryReplyNegotiated: false }) }) }) diff --git a/src/mobile-web/src/mobile-web-terminal-event-state.ts b/src/mobile-web/src/mobile-web-terminal-event-state.ts index 46756600429..17e465a66fe 100644 --- a/src/mobile-web/src/mobile-web-terminal-event-state.ts +++ b/src/mobile-web/src/mobile-web-terminal-event-state.ts @@ -24,15 +24,9 @@ export type MobileWebTerminalEffect = type: 'ready' sequence: number viewport: { cols: number; rows: number } - inputFloor: 'held' | 'read-only' - queryReplyAuthority: boolean - } - | { - type: 'authority' - displayMode: 'auto' | 'desktop' - inputFloor: 'held' | 'read-only' - queryReplyAuthority: boolean + queryReplyNegotiated: boolean } + | { type: 'displayMode'; displayMode: 'auto' | 'desktop' } | { type: 'write'; data: Uint8Array; throughSequence: number } | { type: 'replace' @@ -64,8 +58,7 @@ export class MobileWebTerminalEventState { type: 'ready', sequence: event.startSequence, viewport: event.viewport, - inputFloor: event.inputFloor, - queryReplyAuthority: event.queryReplyAuthority + queryReplyNegotiated: event.queryReplyNegotiated === true } } if (event.type === 'output') { @@ -95,12 +88,7 @@ export class MobileWebTerminalEventState { return { type: 'closed' } } if (event.type === 'metadata') { - return { - type: 'authority', - displayMode: event.displayMode, - inputFloor: event.inputFloor, - queryReplyAuthority: event.queryReplyAuthority - } + return { type: 'displayMode', displayMode: event.displayMode } } if (event.type === 'resized') { return { type: 'resized', viewport: event.viewport } diff --git a/src/mobile-web/src/mobile-web-terminal-request-scheduler-authority.test.ts b/src/mobile-web/src/mobile-web-terminal-request-scheduler-authority.test.ts index 028d67e1f1a..c5525f5609a 100644 --- a/src/mobile-web/src/mobile-web-terminal-request-scheduler-authority.test.ts +++ b/src/mobile-web/src/mobile-web-terminal-request-scheduler-authority.test.ts @@ -9,7 +9,7 @@ describe('MobileWebTerminalRequestScheduler authority lifecycle', () => { it('drops host readiness and write authority when the terminal goes invisible', async () => { const harness = createHarness() harness.scheduler.markBridgeReady() - harness.scheduler.markHostReady('held', true) + harness.scheduler.markHostReady(true) await expect(harness.scheduler.sendInputAsync('input', 'YQ==')).resolves.toBe(true) expect(harness.operations('input')).toHaveLength(1) @@ -33,14 +33,14 @@ describe('MobileWebTerminalRequestScheduler authority lifecycle', () => { const harness = createHarness((request) => request.operation === 'input' && inFlight.pending() ? inFlight.promise : Promise.resolve(null) ) - harness.scheduler.markHostReady('held', true) + harness.scheduler.markHostReady(true) const first = harness.scheduler.sendInputAsync('input', 'YQ==') const queued = harness.scheduler.sendInputAsync('input', 'Yg==') await vi.waitFor(() => expect(harness.operations('input')).toHaveLength(1)) // Authority is revoked while the queued write is still parked behind the in-flight one. - harness.scheduler.setAuthority('read-only', false) + harness.scheduler.setVisible(false) inFlight.resolve(null) await expect(first).resolves.toBe(true) @@ -48,14 +48,27 @@ describe('MobileWebTerminalRequestScheduler authority lifecycle', () => { expect(harness.operations('input')).toHaveLength(1) }) + it('sends a query reply only when the shell reported a negotiated reply opcode', async () => { + const harness = createHarness() + harness.scheduler.markHostReady(false) + + await expect(harness.scheduler.sendInputAsync('queryReply', 'YQ==')).resolves.toBe(false) + await expect(harness.scheduler.sendInputAsync('input', 'Yg==')).resolves.toBe(true) + harness.scheduler.markHostReady(true) + await expect(harness.scheduler.sendInputAsync('queryReply', 'Yw==')).resolves.toBe(true) + + expect(harness.operations('queryReply')).toHaveLength(1) + expect(harness.operations('input')).toHaveLength(1) + }) + it('makes every entry point inert after dispose', async () => { const harness = createHarness() harness.scheduler.markBridgeReady() - harness.scheduler.markHostReady('held', true) + harness.scheduler.markHostReady(true) harness.scheduler.dispose() harness.scheduler.markBridgeReady() - harness.scheduler.markHostReady('held', true) + harness.scheduler.markHostReady(true) harness.scheduler.acknowledge(4) harness.scheduler.resize({ cols: 80, rows: 24 }) harness.scheduler.setVisible(true) diff --git a/src/mobile-web/src/mobile-web-terminal-request-scheduler.test.ts b/src/mobile-web/src/mobile-web-terminal-request-scheduler.test.ts index fcb2e0d1621..597ae546463 100644 --- a/src/mobile-web/src/mobile-web-terminal-request-scheduler.test.ts +++ b/src/mobile-web/src/mobile-web-terminal-request-scheduler.test.ts @@ -11,7 +11,7 @@ describe('MobileWebTerminalRequestScheduler', () => { const first = deferred() const terminalRequest = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue(null) const scheduler = createScheduler(terminalRequest) - scheduler.markHostReady('held', true) + scheduler.markHostReady(true) scheduler.sendInput('input', 'YQ==') scheduler.sendInput('input', 'Yg==') @@ -26,7 +26,7 @@ describe('MobileWebTerminalRequestScheduler', () => { const terminalRequest = vi.fn().mockRejectedValueOnce(new Error('busy')).mockResolvedValue(null) const onError = vi.fn() const scheduler = createScheduler(terminalRequest, onError) - scheduler.markHostReady('held', true) + scheduler.markHostReady(true) scheduler.sendInput('input', 'YQ==') scheduler.sendInput('input', 'Yg==') @@ -34,7 +34,7 @@ describe('MobileWebTerminalRequestScheduler', () => { expect(terminalRequest.mock.calls.map(([request]) => request.sequence)).toEqual([0, 0]) expect(onError).toHaveBeenCalledOnce() - scheduler.setAuthority('read-only', false) + scheduler.setVisible(false) scheduler.sendInput('input', 'Yw==') scheduler.sendInput('queryReply', 'ZA==') await Promise.resolve() @@ -54,7 +54,7 @@ describe('MobileWebTerminalRequestScheduler', () => { return Promise.resolve(null) }) const scheduler = createScheduler(terminalRequest) - scheduler.markHostReady('held', true) + scheduler.markHostReady(true) scheduler.acknowledge(5) scheduler.acknowledge(8) @@ -91,7 +91,7 @@ describe('MobileWebTerminalRequestScheduler', () => { scheduler.requestResync(4, 'gap') expect(terminalRequest).toHaveBeenCalledTimes(1) - scheduler.markHostReady('held', true) + scheduler.markHostReady(true) scheduler.requestResync(4, 'gap') scheduler.requestResync(4, 'gap') await vi.waitFor(() => expect(terminalRequest).toHaveBeenCalledTimes(2)) @@ -129,7 +129,7 @@ describe('MobileWebTerminalRequestScheduler', () => { .mockResolvedValueOnce({ status: 'accepted' }) .mockResolvedValueOnce({ status: 'cancelled' }) const scheduler = createScheduler(terminalRequest, vi.fn(), terminalDeviceInputRequest) - scheduler.markHostReady('held', true) + scheduler.markHostReady(true) const input = scheduler.sendInputAsync('input', 'YQ==') const paste = scheduler.pasteClipboard(true) 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 5276f994077..a5d16107f22 100644 --- a/src/mobile-web/src/mobile-web-terminal-request-scheduler.ts +++ b/src/mobile-web/src/mobile-web-terminal-request-scheduler.ts @@ -19,8 +19,7 @@ export class MobileWebTerminalRequestScheduler { private bridgeReady = false private hostReady = false private disposed = false - private inputFloor: 'held' | 'read-only' = 'read-only' - private queryReplyAuthority = false + private queryReplyNegotiated = false private inputSequence = 0 private inputTail = Promise.resolve() private pendingAck: number | null = null @@ -45,22 +44,17 @@ export class MobileWebTerminalRequestScheduler { this.drainVisibility() } - markHostReady(inputFloor: 'held' | 'read-only', queryReplyAuthority: boolean): void { + markHostReady(queryReplyNegotiated: boolean): void { if (this.disposed) { return } this.hostReady = true this.resyncPending = false - this.setAuthority(inputFloor, queryReplyAuthority) + this.queryReplyNegotiated = queryReplyNegotiated this.drainResize() this.drainAck() } - setAuthority(inputFloor: 'held' | 'read-only', queryReplyAuthority: boolean): void { - this.inputFloor = inputFloor - this.queryReplyAuthority = queryReplyAuthority - } - sendInput(operation: InputOperation, data: string): void { void this.sendInputAsync(operation, data) } @@ -131,8 +125,7 @@ export class MobileWebTerminalRequestScheduler { } if (!visible) { this.hostReady = false - this.inputFloor = 'read-only' - this.queryReplyAuthority = false + this.queryReplyNegotiated = false this.pendingAck = null this.resyncPending = false } @@ -185,10 +178,7 @@ export class MobileWebTerminalRequestScheduler { } private canSendInput(operation: InputOperation): boolean { - return ( - this.hostReady && - (operation === 'queryReply' ? this.queryReplyAuthority : this.inputFloor === 'held') - ) + return this.hostReady && (operation !== 'queryReply' || this.queryReplyNegotiated) } private drainAck(): void { diff --git a/src/shared/mobile-web/terminal-stream-contract.test.ts b/src/shared/mobile-web/terminal-stream-contract.test.ts index a727cdcf942..67ccdc94ea5 100644 --- a/src/shared/mobile-web/terminal-stream-contract.test.ts +++ b/src/shared/mobile-web/terminal-stream-contract.test.ts @@ -229,9 +229,7 @@ describe('mobile web terminal snapshot and lifecycle contract', () => { { type: 'metadata', streamId: STREAM_ID, - displayMode: 'auto', - inputFloor: 'held', - queryReplyAuthority: true + displayMode: 'auto' }, { type: 'closed', streamId: STREAM_ID, reason: 'terminal-exited' }, { type: 'error', streamId: STREAM_ID, code: 'host_error', recoverable: true } diff --git a/src/shared/mobile-web/terminal-stream-contract.ts b/src/shared/mobile-web/terminal-stream-contract.ts index 3179249f7d0..fdec738666c 100644 --- a/src/shared/mobile-web/terminal-stream-contract.ts +++ b/src/shared/mobile-web/terminal-stream-contract.ts @@ -142,8 +142,10 @@ const SubscribedEventSchema = z viewport: ViewportSchema, startSequence: SequenceSchema, maxOutstandingBytes: z.literal(MOBILE_WEB_TERMINAL_MAX_OUTSTANDING_BYTES), - inputFloor: z.enum(['held', 'read-only']), - queryReplyAuthority: z.boolean() + // Whether the host negotiated opcode 18 on this stream, not an election verdict: the host + // re-checks reply authority per frame and never publishes it. Absent means a shell that + // predates the field, which cannot prove negotiation, so the page must not attempt a reply. + queryReplyNegotiated: z.boolean().optional() }) .strict() @@ -215,9 +217,7 @@ const MetadataEventSchema = z .object({ type: z.literal('metadata'), streamId: StreamIdSchema, - displayMode: z.enum(['auto', 'desktop']), - inputFloor: z.enum(['held', 'read-only']), - queryReplyAuthority: z.boolean() + displayMode: z.enum(['auto', 'desktop']) }) .strict() const ClosedEventSchema = z