diff --git a/src/main/persistence/applying-settings/settings-update-terminal-quick-commands.test.ts b/src/main/persistence/applying-settings/settings-update-terminal-quick-commands.test.ts index f6f16a37bc6..a0651f4d5af 100644 --- a/src/main/persistence/applying-settings/settings-update-terminal-quick-commands.test.ts +++ b/src/main/persistence/applying-settings/settings-update-terminal-quick-commands.test.ts @@ -8,7 +8,7 @@ import { MAX_TERMINAL_QUICK_COMMANDS_SERIALIZED_BYTES } from '../../../shared/terminal-quick-command-prompt-limit' import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../../shared/remote-rpc-content-budget' -import { WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES } from '../../runtime/rpc/ws-transport' +import { REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { updateSettings, type SettingsMutationOperations } from './settings-update' import { CreateAgentSessionParams, @@ -124,10 +124,10 @@ describe('the quick-command storage bound against the paired transports', () => }) it('fits one saved command in an inbound frame at the worst JSON escape', () => { - // Legacy E2EE frames are base64 of nonce + MAC + plaintext. - const inboundPlaintextBytes = Math.floor(WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES / 4) * 3 - 40 const worstCasePromptBytes = MAX_QUICK_COMMAND_AGENT_PROMPT_MESSAGE_BYTES const envelopeReserveBytes = 16 * 1024 - expect(worstCasePromptBytes + envelopeReserveBytes).toBeLessThanOrEqual(inboundPlaintextBytes) + expect(worstCasePromptBytes + envelopeReserveBytes).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES + ) }) }) diff --git a/src/main/ssh/orcad-migration-catalog-client-frame-budget.test.ts b/src/main/ssh/orcad-migration-catalog-client-frame-budget.test.ts new file mode 100644 index 00000000000..162059752e6 --- /dev/null +++ b/src/main/ssh/orcad-migration-catalog-client-frame-budget.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcadMigrationBlobDescriptor } from '../../shared/orcad-migration-blob-transfer' +import { + MAX_ORCAD_MIGRATION_MANIFEST_BYTES, + ORCAD_MIGRATION_MANIFEST_VERSION, + type OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import { + ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY +} from '../../shared/orcad-runtime-capabilities' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' + +const sendRequest = vi.hoisted(() => vi.fn()) +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequestWithStatusPreflight: async ( + pairing: unknown, + method: string, + params: unknown, + _timeoutMs: number, + validate: (response: unknown) => void + ) => { + validate({ + ok: true, + _meta: { runtimeId: 'runtime' }, + result: { + capabilities: [ + ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, + 'orcad.migration-blob.agent-session-records.v1' + ] + } + }) + return sendRequest(pairing, method, params) + } +})) + +const { commitRemoteOrcadMigrationCatalog, stageRemoteOrcadMigrationCatalog } = + await import('./orcad-migration-catalog-client') + +const pairingCode = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'ws://127.0.0.1:46768/runtime', + deviceToken: 'device-token', + publicKeyB64: 'public-key', + pairedDeviceId: 'paired-desktop' +}) + +/** A manifest whose JSON is exactly `bytes` long. */ +function manifestOfBytes(bytes: number): OrcadMigrationManifest { + const withLabel = (targetLabel: string): OrcadMigrationManifest => ({ + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-frame', + createdAt: '2026-10-18T12:00:00.000Z', + source: { sshTargetId: 'source', sshTargetGeneration: 1, targetLabel }, + payload: { repositories: [], projectGroups: [], folderWorkspaces: [] }, + manifestSha256: 'a'.repeat(64) + }) + return withLabel('x'.repeat(bytes - JSON.stringify(withLabel('')).length)) +} + +const manyBlobs: OrcadMigrationBlobDescriptor[] = Array.from({ length: 200 }, (_, index) => ({ + blobKind: 'agent-session-records', + key: `session-${index}-${'s'.repeat(64)}`, + sha256: 'c'.repeat(64), + byteLength: 1024 +})) + +beforeEach(() => { + vi.clearAllMocks() + sendRequest.mockResolvedValue({ + ok: true, + _meta: { runtimeId: 'runtime' }, + result: { + state: 'staged', + migrationId: 'migration-frame', + manifestSha256: 'a'.repeat(64), + stagedAt: '2026-10-18T12:00:00.000Z' + } + }) +}) + +describe("orcad migration requests fit the host's sealed inbound frame", () => { + it('stages a large manifest that still fits', async () => { + await stageRemoteOrcadMigrationCatalog(pairingCode, manifestOfBytes(760 * 1024)) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('refuses, unsent, a manifest at the validation cap: sealed, it overflows the 1 MiB frame', async () => { + await expect( + stageRemoteOrcadMigrationCatalog( + pairingCode, + manifestOfBytes(MAX_ORCAD_MIGRATION_MANIFEST_BYTES) + ) + ).rejects.toThrow(/\(orcad_migration_manifest_too_large\)$/) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('refuses, unsent, a commit whose blob list pushes a fitting manifest past the frame', async () => { + const manifest = manifestOfBytes(760 * 1024) + await expect( + commitRemoteOrcadMigrationCatalog(pairingCode, manifest, { expectedBlobs: manyBlobs }) + ).rejects.toThrow(/too large to move to its Orca server/) + expect(sendRequest).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/orcad-migration-catalog-client.ts b/src/main/ssh/orcad-migration-catalog-client.ts index 83885dd67aa..4cd3ff352c9 100644 --- a/src/main/ssh/orcad-migration-catalog-client.ts +++ b/src/main/ssh/orcad-migration-catalog-client.ts @@ -26,6 +26,8 @@ import { parsePairingCode } from '../../shared/pairing' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities' import { sendRemoteRuntimeRequestWithStatusPreflight } from '../../shared/remote-runtime-client' +import { REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES } from '../../shared/remote-runtime-memory-limits' +import { describeOversizedOrcadMigrationPayload } from './orcad-migration-manifest-export' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import { AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { hasExtraAgentArgs } from '../../shared/automation-extra-agent-args' @@ -53,6 +55,9 @@ export type CatalogRequestOptions = { expectedRuntimeId?: string } +// Request id, device token, method name and JSON framing around the params. +const REQUEST_ENVELOPE_RESERVE_BYTES = 1024 + const METHOD_BY_OPERATION: Record = { abort: 'orcad.migration.abortCatalog', commit: 'orcad.migration.commitCatalog', @@ -222,6 +227,22 @@ async function request( if (!pairing) { throw new Error('orcad_migration_pairing_code_invalid') } + // Only with blobs: SSH and blob-free moves keep sending exactly `{ manifest }`. + const params = expectedBlobs.length > 0 ? { manifest, expectedBlobs } : { manifest } + // A manifest under the validation cap can still overflow the host's sealed frame (more so with + // expectedBlobs); the host would answer by closing the socket, so refuse before sending. + const paramsBytes = Buffer.byteLength(JSON.stringify(params), 'utf8') + if ( + paramsBytes + REQUEST_ENVELOPE_RESERVE_BYTES > + REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES + ) { + throw new Error( + describeOversizedOrcadMigrationPayload( + manifest.payload, + REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES - REQUEST_ENVELOPE_RESERVE_BYTES + ) + ) + } const carriesExtraAgentArgs = (operation === 'stage' || operation === 'commit') && (manifest.payload.dormantState?.automations ?? []).some((automation) => @@ -230,8 +251,7 @@ async function request( const response = await sendSupportedMigrationRequest( pairing, METHOD_BY_OPERATION[operation], - // Only with blobs: SSH and blob-free moves keep sending exactly `{ manifest }`. - expectedBlobs.length > 0 ? { manifest, expectedBlobs } : { manifest }, + params, options, carriesExtraAgentArgs, // Every operation: an older destination would parse a non-SSH source as malformed or as SSH. diff --git a/src/main/ssh/orcad-migration-manifest-export.ts b/src/main/ssh/orcad-migration-manifest-export.ts index 34beae4950b..3bdb4e1152f 100644 --- a/src/main/ssh/orcad-migration-manifest-export.ts +++ b/src/main/ssh/orcad-migration-manifest-export.ts @@ -104,7 +104,8 @@ const ORCAD_MIGRATION_MANIFEST_TOO_LARGE = 'orcad_migration_manifest_too_large' /** The bare code told users nothing (#30126): name the largest parts and what to trim. */ export function describeOversizedOrcadMigrationPayload( - payload: OrcadMigrationCatalogPayload + payload: OrcadMigrationCatalogPayload, + limitBytes = MAX_ORCAD_MIGRATION_MANIFEST_BYTES ): string { const dormant = payload.dormantState const parts: [string, unknown][] = [ @@ -133,7 +134,7 @@ export function describeOversizedOrcadMigrationPayload( .map(({ words, bytes }) => `${words} (${formatKib(bytes)})`) return ( `This SSH host's saved state is too large to move to its Orca server (limit ` + - `${formatKib(MAX_ORCAD_MIGRATION_MANIFEST_BYTES)}). Largest parts: ${largest.join(', ')}. ` + + `${formatKib(limitBytes)}). Largest parts: ${largest.join(', ')}. ` + `Close tabs you no longer need or remove unused projects for this host, then reconnect. ` + `(${ORCAD_MIGRATION_MANIFEST_TOO_LARGE})` ) diff --git a/src/shared/remote-runtime-memory-limits.test.ts b/src/shared/remote-runtime-memory-limits.test.ts index dfe9d6c787c..4573fe783e5 100644 --- a/src/shared/remote-runtime-memory-limits.test.ts +++ b/src/shared/remote-runtime-memory-limits.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' +import { encrypt } from './e2ee-crypto' +import { sealMobileE2EEV2Frame } from './mobile-e2ee-v2-framing' import { isRemoteRuntimeBinaryFrameWithinLimit, measureRemoteRuntimeSubscriptionParams, + REMOTE_RUNTIME_MAX_INBOUND_FRAME_BYTES, + REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES, REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES, REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, REMOTE_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES, @@ -9,6 +13,25 @@ import { } from './remote-runtime-memory-limits' describe('remote runtime memory limits', () => { + it('seals the largest admitted request JSON within the host inbound frame, in both framings', () => { + const json = 'x'.repeat(REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES) + const key = new Uint8Array(32).fill(1) + const legacy = encrypt(json, key) + const v2 = Buffer.from( + sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode(json), + key, + sessionId: new Uint8Array(32).fill(2), + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter: 0n + }) + ).toString('base64') + for (const frame of [legacy, v2]) { + expect(frame.length).toBeLessThanOrEqual(REMOTE_RUNTIME_MAX_INBOUND_FRAME_BYTES) + } + }) + it('accepts exact outbound JSON bytes and rejects the next byte', () => { expect( serializeRemoteRuntimePayload('x'.repeat(REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES - 2)) diff --git a/src/shared/remote-runtime-memory-limits.ts b/src/shared/remote-runtime-memory-limits.ts index 3d1812d97ea..b70795ca2ca 100644 --- a/src/shared/remote-runtime-memory-limits.ts +++ b/src/shared/remote-runtime-memory-limits.ts @@ -8,6 +8,12 @@ import type { RuntimeOrchestrationEnvelope } from './runtime-rpc-envelope' export const REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES = 4 * 1024 * 1024 // Why: a host refuses a larger client frame by closing the socket (1009), failing every call on it. export const REMOTE_RUNTIME_MAX_INBOUND_FRAME_BYTES = 1024 * 1024 +// Why: a sealed frame is base64 of nonce, MAC and (v2) a 42-byte header around the request JSON. +const REMOTE_RUNTIME_MAX_SEALING_OVERHEAD_BYTES = 128 +/** The largest request JSON that still fits a host's inbound frame once sealed. */ +export const REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES = + Math.floor(REMOTE_RUNTIME_MAX_INBOUND_FRAME_BYTES / 4) * 3 - + REMOTE_RUNTIME_MAX_SEALING_OVERHEAD_BYTES // Why: sealing inflates a request by up to 4/3, so this much JSON-escaped content still fits a frame. export const REMOTE_RUNTIME_MAX_FILE_WRITE_CONTENT_BYTES = 720 * 1024 export const REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES = 8 * 1024 * 1024 + 64