fix(ssh): refuse a migration request too large for the host's sealed frame before sending it (#30249)

A manifest under the 768 KiB validation cap can still overflow the host's 1 MiB inbound
frame once sealed (base64 adds a third), more so when a commit carries its blob list. The
host answers such a frame by closing the socket, so the move failed with a lost answer.
The catalog client now measures the request against REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES
and refuses it with the existing 'too large' guidance, unsent.
This commit is contained in:
Neil
2026-10-18 00:50:59 -07:00
committed by GitHub
parent 29a43a73ed
commit 8f86ccf764
6 changed files with 164 additions and 8 deletions
@@ -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
)
})
})
@@ -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()
})
})
+22 -2
View File
@@ -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<OrcadCatalogMigrationOperation, string> = {
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.
@@ -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})`
)
@@ -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))
@@ -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