mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
+4
-4
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user