mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
fix(terminals): negotiate explicit close intent for paired runtimes (#10129)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultRepoHookSettings } from '../../shared/constants'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { parsePairingCode } from '../../shared/pairing'
|
||||
@@ -25,6 +25,72 @@ const passthroughDedupe = <T>(_repo: string, _id: string | undefined, run: () =>
|
||||
run()
|
||||
|
||||
describe('remote runtime request connection integration', () => {
|
||||
it(
|
||||
'binds encrypted close-intent capability to the real runtime RPC context',
|
||||
{ timeout: REMOTE_RUNTIME_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-close-intent-'))
|
||||
const refuseUnattributedMobileSessionTabClose = vi.fn().mockResolvedValue({
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'missing-intent',
|
||||
snapshotRepublished: true
|
||||
})
|
||||
const closeMobileSessionTab = vi.fn()
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'close-intent-runtime-test',
|
||||
getStartedAt: () => 1,
|
||||
cleanupSubscriptionsForConnection: () => {},
|
||||
cancelMobileDictationForConnection: () => {},
|
||||
onClientDisconnected: () => {},
|
||||
refuseUnattributedMobileSessionTabClose,
|
||||
closeMobileSessionTab
|
||||
} as unknown as OrcaRuntimeService
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' })
|
||||
if (!offer.available) {
|
||||
throw new Error('pairing unavailable')
|
||||
}
|
||||
const pairing = parsePairingCode(offer.pairingUrl)
|
||||
if (!pairing) {
|
||||
throw new Error('invalid pairing')
|
||||
}
|
||||
const connection = new RemoteRuntimeRequestConnection(pairing)
|
||||
try {
|
||||
await expect(
|
||||
connection.request(
|
||||
'session.tabs.close',
|
||||
{ worktree: 'id:wt-1', tabId: 'tab-1' },
|
||||
REMOTE_RUNTIME_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
refused: true,
|
||||
refusalReason: 'missing-intent',
|
||||
snapshotRepublished: true
|
||||
}
|
||||
})
|
||||
expect(refuseUnattributedMobileSessionTabClose).toHaveBeenCalledWith('id:wt-1', 'tab-1')
|
||||
expect(closeMobileSessionTab).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
} finally {
|
||||
await server.stop()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it(
|
||||
'fetches repos through the real E2EE WebSocket runtime',
|
||||
{ timeout: REMOTE_RUNTIME_TEST_TIMEOUT_MS },
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
PairingGetEndpointsResult,
|
||||
PairingProvisionRelayParams
|
||||
} from '../../../shared/mobile-relay-credential-contract'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
|
||||
export type PairingRpcContext = {
|
||||
getEndpoints(params: PairingGetEndpointsParams): Promise<PairingGetEndpointsResult>
|
||||
@@ -63,6 +64,8 @@ export type RpcContext = {
|
||||
pairedDeviceId?: string
|
||||
// Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip).
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
// Why: negotiation is bound to the authenticated socket, never asserted by a destructive request.
|
||||
clientCapabilities?: readonly RuntimeCapability[]
|
||||
// Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields.
|
||||
orchestrationCapability?: string
|
||||
// Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles.
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { ALL_RPC_METHODS } from './methods'
|
||||
import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
import {
|
||||
OrchestrationMutationExecutor,
|
||||
authenticatedCallerFingerprint,
|
||||
@@ -125,6 +126,7 @@ export class RpcDispatcher {
|
||||
clientId?: string
|
||||
pairedDeviceId?: string
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
clientCapabilities?: readonly RuntimeCapability[]
|
||||
pairing?: PairingRpcContext
|
||||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
|
||||
registerBinaryStreamHandler?: (
|
||||
@@ -167,6 +169,7 @@ export class RpcDispatcher {
|
||||
clientId: options?.clientId,
|
||||
pairedDeviceId: options?.pairedDeviceId,
|
||||
clientKind: options?.clientKind,
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
authenticatedCallerFingerprint: authenticatedCallerFingerprint(request),
|
||||
recordMutationReceipt: mutation?.recordReceipt,
|
||||
@@ -208,6 +211,7 @@ export class RpcDispatcher {
|
||||
clientId: options?.clientId,
|
||||
pairedDeviceId: options?.pairedDeviceId,
|
||||
clientKind: options?.clientKind,
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
pairing: options?.pairing,
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('E2EEChannel v2', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects legacy downgrade and injected auth metadata when v2 is required', () => {
|
||||
it('rejects legacy downgrade and runtime-only capability metadata when mobile v2 is required', () => {
|
||||
const legacy = setup()
|
||||
legacy.channel.handleRawMessage(
|
||||
JSON.stringify({ type: 'e2ee_hello', publicKeyB64: 'legacy-key' })
|
||||
@@ -173,13 +173,14 @@ describe('E2EEChannel v2', () => {
|
||||
v: 2,
|
||||
transcriptHashB64,
|
||||
deviceToken: 'valid-token',
|
||||
relayDeviceId: 'injected'
|
||||
clientCapabilities: ['session-tabs.close-intent.v1']
|
||||
}),
|
||||
schedule,
|
||||
0n
|
||||
)
|
||||
)
|
||||
expect(ctx.resolveAuthenticatedDevice).not.toHaveBeenCalled()
|
||||
expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid e2ee_auth')
|
||||
})
|
||||
|
||||
it('rejects a captured auth frame replayed onto a fresh desktop nonce', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES
|
||||
} from '../../../shared/remote-runtime-memory-limits'
|
||||
import { REMOTE_RUNTIME_JSON_STRUCTURE_LIMITS } from '../../../shared/remote-runtime-request-frames'
|
||||
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
|
||||
function publicKeyToBase64(key: Uint8Array): string {
|
||||
return Buffer.from(key).toString('base64')
|
||||
@@ -101,6 +102,35 @@ describe('E2EEChannel', () => {
|
||||
expect(JSON.parse(ctx.ws.sent[0]!)).toEqual({ type: 'e2ee_ready' })
|
||||
})
|
||||
|
||||
it('binds runtime capabilities to encrypted authenticated metadata', () => {
|
||||
const ctx = setup({
|
||||
resolveAuthenticatedDevice: (token) =>
|
||||
token === 'valid-token'
|
||||
? { deviceId: 'device-1', deviceToken: token, scope: 'runtime' }
|
||||
: null
|
||||
})
|
||||
ctx.channel.handleRawMessage(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey)
|
||||
})
|
||||
)
|
||||
const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey)
|
||||
ctx.channel.handleRawMessage(
|
||||
encrypt(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_auth',
|
||||
deviceToken: 'valid-token',
|
||||
clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY]
|
||||
}),
|
||||
sharedKey
|
||||
)
|
||||
)
|
||||
|
||||
expect(ctx.channel.clientCapabilities).toEqual([SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY])
|
||||
expect(ctx.onReady).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects invalid encrypted token', () => {
|
||||
const ctx = setup()
|
||||
ctx.channel.handleRawMessage(
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames'
|
||||
import type { MobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget'
|
||||
import { MobileE2EEDesktopOutboundOwner } from './mobile-e2ee-desktop-outbound-owner'
|
||||
import { parseRuntimeClientCapabilities } from './runtime-client-capabilities'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
|
||||
const HANDSHAKE_TIMEOUT_MS = 10_000
|
||||
const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5
|
||||
@@ -62,6 +64,7 @@ export class E2EEChannel {
|
||||
|
||||
deviceToken: string | null = null
|
||||
authenticatedDevice: E2EEAuthenticatedDevice | null = null
|
||||
clientCapabilities: readonly RuntimeCapability[] = []
|
||||
|
||||
constructor(ws: WebSocket, options: E2EEChannelOptions) {
|
||||
this.ws = ws
|
||||
@@ -246,6 +249,7 @@ export class E2EEChannel {
|
||||
}
|
||||
const authenticatedDevice = authentication.device
|
||||
|
||||
this.clientCapabilities = parseRuntimeClientCapabilities(authentication.auth.clientCapabilities)
|
||||
this.deviceToken = authenticatedDevice.deviceToken
|
||||
this.authenticatedDevice = authenticatedDevice
|
||||
this.state = 'ready'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withSpan } from '../../../observability/tracer'
|
||||
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas'
|
||||
|
||||
@@ -6,11 +7,17 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'session.tabs.close',
|
||||
params: CloseTab,
|
||||
handler: async (params, context) =>
|
||||
withSpan(
|
||||
handler: async (params, context) => {
|
||||
const requiresIntent =
|
||||
context.clientKind === undefined ||
|
||||
(context.clientKind === 'runtime' &&
|
||||
context.clientCapabilities?.includes(SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY) ===
|
||||
true)
|
||||
return withSpan(
|
||||
'runtime.session-tabs.close',
|
||||
async (span) => {
|
||||
if (!params.reason && context.clientKind === undefined) {
|
||||
// Why: old runtime clicks and cleanup are wire-identical, so changing their behavior would regress mixed-version pairings.
|
||||
if (!params.reason && requiresIntent) {
|
||||
const result = await context.runtime.refuseUnattributedMobileSessionTabClose(
|
||||
params.worktree,
|
||||
params.tabId
|
||||
@@ -36,12 +43,17 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
|
||||
origin: context.clientKind ?? 'in-process',
|
||||
closeReason:
|
||||
params.reason ??
|
||||
(context.clientKind ? `legacy-${context.clientKind}-user` : 'missing'),
|
||||
(requiresIntent
|
||||
? 'missing'
|
||||
: context.clientKind === 'mobile'
|
||||
? 'legacy-mobile-user'
|
||||
: 'legacy-runtime-user'),
|
||||
connectionGeneration: context.connectionId ?? 'in-process',
|
||||
requestId: context.requestId ?? 'in-process'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'session.tabs.closeLifecycle',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import { SESSION_TAB_METHODS } from './session-tabs'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
@@ -114,6 +115,32 @@ describe('session tab RPC methods', () => {
|
||||
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves explicit user closes from current runtime clients', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
refuseUnattributedMobileSessionTabClose: vi.fn(),
|
||||
closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
const replies: string[] = []
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('session.tabs.close', {
|
||||
worktree: 'id:wt-1',
|
||||
tabId: 'tab-1',
|
||||
reason: 'user'
|
||||
}),
|
||||
(response) => replies.push(response),
|
||||
{ clientKind: 'runtime', pairedDeviceId: 'current-runtime' }
|
||||
)
|
||||
|
||||
expect(replies).toHaveLength(1)
|
||||
expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', {
|
||||
reason: 'user'
|
||||
})
|
||||
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves reasonless explicit closes from authenticated legacy mobile clients', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
@@ -136,7 +163,7 @@ describe('session tab RPC methods', () => {
|
||||
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves reasonless explicit closes from authenticated legacy runtime clients', async () => {
|
||||
it('preserves reasonless closes from authenticated legacy runtime clients', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
refuseUnattributedMobileSessionTabClose: vi.fn(),
|
||||
@@ -158,6 +185,35 @@ describe('session tab RPC methods', () => {
|
||||
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses reasonless closes from runtime clients that negotiated explicit intent', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'missing-intent',
|
||||
snapshotRepublished: true
|
||||
}),
|
||||
closeMobileSessionTab: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
const replies: string[] = []
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('session.tabs.close', { worktree: 'id:wt-1', tabId: 'tab-1' }),
|
||||
(response) => replies.push(response),
|
||||
{
|
||||
clientKind: 'runtime',
|
||||
pairedDeviceId: 'current-runtime',
|
||||
clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY]
|
||||
}
|
||||
)
|
||||
|
||||
expect(replies).toHaveLength(1)
|
||||
expect(runtime.refuseUnattributedMobileSessionTabClose).toHaveBeenCalledWith('id:wt-1', 'tab-1')
|
||||
expect(runtime.closeMobileSessionTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['pty-exit', 'cleanup'] as const)(
|
||||
'rejects %s on the legacy close endpoint before host adjudication',
|
||||
async (reason) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-reque
|
||||
export type MobileE2EEAuth = {
|
||||
type: 'e2ee_auth'
|
||||
deviceToken: string
|
||||
clientCapabilities?: unknown
|
||||
v?: 2
|
||||
transcriptHashB64?: string
|
||||
}
|
||||
@@ -16,6 +17,7 @@ export function isValidMobileE2EEAuthVersion(
|
||||
if (!v2Session) {
|
||||
return auth.v === undefined && auth.transcriptHashB64 === undefined
|
||||
}
|
||||
// Why: mobile v2 keeps an exact transcript-bound shape; runtime capabilities use legacy paired-runtime auth.
|
||||
return (
|
||||
Object.keys(auth).sort().join(',') === 'deviceToken,transcriptHashB64,type,v' &&
|
||||
auth.v === 2 &&
|
||||
@@ -27,7 +29,9 @@ export function authenticateMobileE2EE<TDevice extends { deviceToken: string }>(
|
||||
plaintext: string
|
||||
v2Session: DesktopMobileE2EEV2Session | null
|
||||
resolveDevice: (token: string) => TDevice | null
|
||||
}): { ok: true; device: TDevice } | { ok: false; code: 'bad_auth' | 'unauthorized' } {
|
||||
}):
|
||||
| { ok: true; device: TDevice; auth: MobileE2EEAuth }
|
||||
| { ok: false; code: 'bad_auth' | 'unauthorized' } {
|
||||
let auth: MobileE2EEAuth
|
||||
try {
|
||||
auth = parseRemoteRuntimeJsonText(args.plaintext) as MobileE2EEAuth
|
||||
@@ -43,7 +47,7 @@ export function authenticateMobileE2EE<TDevice extends { deviceToken: string }>(
|
||||
}
|
||||
const device = args.resolveDevice(auth.deviceToken)
|
||||
return device?.deviceToken === auth.deviceToken
|
||||
? { ok: true, device }
|
||||
? { ok: true, device, auth }
|
||||
: { ok: false, code: 'unauthorized' }
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,11 @@ class FakeTransport implements MobileSocketTransport {
|
||||
}
|
||||
}
|
||||
|
||||
function registryFor(deviceId: string, token: string): DeviceRegistry {
|
||||
function registryFor(
|
||||
deviceId: string,
|
||||
token: string,
|
||||
scope: 'mobile' | 'runtime' = 'mobile'
|
||||
): DeviceRegistry {
|
||||
return {
|
||||
validateToken: (candidate: string) =>
|
||||
candidate === token
|
||||
@@ -57,7 +61,7 @@ function registryFor(deviceId: string, token: string): DeviceRegistry {
|
||||
deviceId,
|
||||
token,
|
||||
name: 'Phone',
|
||||
scope: 'mobile' as const,
|
||||
scope,
|
||||
pairedAt: 1,
|
||||
lastSeenAt: 0
|
||||
}
|
||||
@@ -138,7 +142,7 @@ describe('MobileSocketWiring', () => {
|
||||
const onText = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const wiring = new MobileSocketWiring({
|
||||
deviceRegistry: registryFor('device-1', 'valid-token'),
|
||||
deviceRegistry: registryFor('device-1', 'valid-token', 'runtime'),
|
||||
e2eeKeypair: {
|
||||
publicKey: desktop.publicKey,
|
||||
secretKey: desktop.secretKey,
|
||||
@@ -160,14 +164,22 @@ describe('MobileSocketWiring', () => {
|
||||
const sharedKey = deriveSharedKey(phone.secretKey, desktop.publicKey)
|
||||
transport.receive(
|
||||
ws,
|
||||
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey)
|
||||
encrypt(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_auth',
|
||||
deviceToken: 'valid-token',
|
||||
clientCapabilities: ['session-tabs.close-intent.v1']
|
||||
}),
|
||||
sharedKey
|
||||
)
|
||||
)
|
||||
transport.receive(ws, encrypt('{"id":"rpc-1","method":"status.get"}', sharedKey))
|
||||
|
||||
expect(transport.setClientId).toHaveBeenCalledWith(ws, 'valid-token')
|
||||
expect(onText).toHaveBeenCalledOnce()
|
||||
expect(onText.mock.calls[0]?.[0]).toMatchObject({
|
||||
device: { deviceId: 'device-1', deviceToken: 'valid-token', scope: 'mobile' },
|
||||
device: { deviceId: 'device-1', deviceToken: 'valid-token', scope: 'runtime' },
|
||||
clientCapabilities: ['session-tabs.close-intent.v1'],
|
||||
transport: { transport: 'direct' }
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DeviceEntry, DeviceRegistry } from '../device-registry'
|
||||
import type { E2EEKeypair } from '../e2ee-keypair'
|
||||
import { E2EEChannel, type E2EEAuthenticatedDevice } from './e2ee-channel'
|
||||
import { createMobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
|
||||
type MobileSocketPayload = string | Uint8Array<ArrayBufferLike>
|
||||
|
||||
@@ -36,6 +37,7 @@ export type AuthenticatedMobileSocket = {
|
||||
ws: WebSocket
|
||||
connectionId: string
|
||||
device: E2EEAuthenticatedDevice
|
||||
clientCapabilities: readonly RuntimeCapability[]
|
||||
transport: MobileSocketTransportMetadata
|
||||
}
|
||||
|
||||
@@ -158,8 +160,14 @@ export class MobileSocketWiring {
|
||||
}
|
||||
return toAuthenticatedDevice(device)
|
||||
},
|
||||
onReady: (_channel, device) => {
|
||||
const socket = { ws, connectionId, device, transport: metadata }
|
||||
onReady: (channel, device) => {
|
||||
const socket = {
|
||||
ws,
|
||||
connectionId,
|
||||
device,
|
||||
clientCapabilities: channel.clientCapabilities,
|
||||
transport: metadata
|
||||
}
|
||||
this.authenticatedSockets.set(ws, socket)
|
||||
transport.setClientId(ws, device.deviceToken)
|
||||
this.deviceRegistry.updateLastSeen(device.deviceId)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseRuntimeClientCapabilities } from './runtime-client-capabilities'
|
||||
|
||||
describe('parseRuntimeClientCapabilities', () => {
|
||||
it('accepts a bounded string array', () => {
|
||||
expect(parseRuntimeClientCapabilities(['session-tabs.close-intent.v1', 'future.v1'])).toEqual([
|
||||
'session-tabs.close-intent.v1',
|
||||
'future.v1'
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'session-tabs.close-intent.v1',
|
||||
[7],
|
||||
[''],
|
||||
['x'.repeat(129)],
|
||||
Array.from({ length: 65 }, () => 'future.v1')
|
||||
])('rejects malformed or oversized capability input', (value) => {
|
||||
expect(parseRuntimeClientCapabilities(value)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
|
||||
export function parseRuntimeClientCapabilities(value: unknown): readonly RuntimeCapability[] {
|
||||
if (!Array.isArray(value) || value.length > 64) {
|
||||
return []
|
||||
}
|
||||
const capabilities = value.filter(
|
||||
(capability): capability is RuntimeCapability =>
|
||||
typeof capability === 'string' && capability.length > 0 && capability.length <= 128
|
||||
)
|
||||
return capabilities.length === value.length ? capabilities : []
|
||||
}
|
||||
@@ -1218,6 +1218,7 @@ export class OrcaRuntimeRpcServer {
|
||||
pairedDeviceId: device.deviceId,
|
||||
// Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated.
|
||||
clientKind: device.scope,
|
||||
clientCapabilities: authenticatedSocket?.clientCapabilities,
|
||||
pairing: pairingContext,
|
||||
signal: abortRegistration?.signal,
|
||||
sendBinary,
|
||||
|
||||
Reference in New Issue
Block a user