fix(terminals): negotiate explicit close intent for paired runtimes (#10129)

This commit is contained in:
OrcaWin
2026-07-27 15:22:55 -07:00
committed by GitHub
parent 0f91af821d
commit 24706ccff0
29 changed files with 1231 additions and 45 deletions
@@ -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 },
+3
View File
@@ -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.
+4
View File
@@ -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
+3 -2
View File
@@ -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', () => {
+30
View File
@@ -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(
+4
View File
@@ -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' }
})
+10 -2
View File
@@ -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 : []
}
+1
View File
@@ -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,