From a75748ae32bda582da2a91ff9c14ef3df68c8b85 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:32:06 -0700 Subject: [PATCH] fix(orcad-migration): refuse before fencing a manifest no catalog request can carry (#30836) Export accepted manifests up to the 768 KiB validation cap, but every catalog request (stage, state, abort) refuses params over the host's sealed-frame budget, about 1.1 KiB lower. A manifest in that window was journaled and fenced, then every way forward or back refused before sending, leaving the host fenced. Measure the request budget once, in the export module, and check it before the fence and before a delta's journal. Co-authored-by: Claude --- .../ssh/orcad-migration-catalog-client.ts | 19 ++----------- .../ssh/orcad-migration-delta-move.test.ts | 20 ++++++++++++++ src/main/ssh/orcad-migration-delta-move.ts | 5 ++++ .../ssh/orcad-migration-manifest-export.ts | 27 +++++++++++++++++++ .../ssh/orcad-migration-source-fence.test.ts | 23 ++++++++++++++-- src/main/ssh/orcad-migration-source-fence.ts | 7 ++++- 6 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/main/ssh/orcad-migration-catalog-client.ts b/src/main/ssh/orcad-migration-catalog-client.ts index 984a881989c..f6e92d27f9f 100644 --- a/src/main/ssh/orcad-migration-catalog-client.ts +++ b/src/main/ssh/orcad-migration-catalog-client.ts @@ -26,8 +26,7 @@ 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 { assertOrcadMigrationRequestFits } 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' @@ -55,9 +54,6 @@ 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', @@ -231,18 +227,7 @@ async function request( 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 - ) - ) - } + assertOrcadMigrationRequestFits(params) const carriesExtraAgentArgs = (operation === 'stage' || operation === 'commit') && (manifest.payload.dormantState?.automations ?? []).some((automation) => diff --git a/src/main/ssh/orcad-migration-delta-move.test.ts b/src/main/ssh/orcad-migration-delta-move.test.ts index 41e330904c2..8c6f12e1934 100644 --- a/src/main/ssh/orcad-migration-delta-move.test.ts +++ b/src/main/ssh/orcad-migration-delta-move.test.ts @@ -31,6 +31,7 @@ import { orcadMigrationCatalogIds } from './orcad-migration-manifest-export' import { latestOrcadMigrationInto } from './orcad-migration-rollback-mark' +import { MAX_ORCAD_MIGRATION_MANIFEST_BYTES } from '../../shared/orcad-migration/orcad-migration-manifest-validation' import { retainOrcadMigrationSource } from './orcad-migration-source-retention' import { reconcileManagedOrcadSshTargets, visibleRepos } from './orcad-retained-source' import { SshConnectionStore } from './connection/ssh-connection-store' @@ -315,6 +316,25 @@ describe('moving what an older build added to a converted host', () => { expect(store.getSshTarget(TARGET.id)?.orcadFence?.sourceChangedAt).toBeUndefined() }) + it('refuses, unjournaled, a delta under the export cap that no request can carry', async () => { + await convertedThenChangedOnOlderBuild() + const baseline = JSON.stringify( + planOrcadDeltaMove(userDataPath, store, store.getSshTarget(TARGET.id)!).manifest + ).length + // Under the export cap, over the sealed-frame request budget: stage, state and abort all refuse. + store.updateRepo(store.getRepo('repo-2')!, { + displayName: 'x'.repeat(MAX_ORCAD_MIGRATION_MANIFEST_BYTES - 200 - baseline) + }) + const plan = planOrcadDeltaMove(userDataPath, store, store.getSshTarget(TARGET.id)!) + expect(JSON.stringify(plan.manifest).length).toBeLessThanOrEqual( + MAX_ORCAD_MIGRATION_MANIFEST_BYTES + ) + + await expect(deltaMove()).rejects.toThrow(/\(orcad_migration_manifest_too_large\)$/) + expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(1) + expect(destination.commits).toBe(1) + }) + it('fails the whole delta when the server already holds a colliding row', async () => { await convertedThenChangedOnOlderBuild() destination.stage.mockRejectedValueOnce( diff --git a/src/main/ssh/orcad-migration-delta-move.ts b/src/main/ssh/orcad-migration-delta-move.ts index b3a10bd537d..f83392a8c52 100644 --- a/src/main/ssh/orcad-migration-delta-move.ts +++ b/src/main/ssh/orcad-migration-delta-move.ts @@ -35,6 +35,7 @@ import { unfinishedOrcadDelta, type OrcadDeltaMovePlan } from './orcad-migration-delta-plan' +import { assertOrcadMigrationRequestFits } from './orcad-migration-manifest-export' import { retainOrcadMigrationSource } from './orcad-migration-source-retention' import { assessOrcadMigrationTerminals, @@ -77,6 +78,10 @@ export async function runOrcadDeltaMove(args: OrcadDeltaMoveArgs): Promise ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES + ) { + throw new Error( + describeOversizedOrcadMigrationPayload( + params.manifest.payload, + ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES + ) + ) + } +} + /** The bare code told users nothing (#30126): name the largest parts and what to trim. */ export function describeOversizedOrcadMigrationPayload( payload: OrcadMigrationCatalogPayload, diff --git a/src/main/ssh/orcad-migration-source-fence.test.ts b/src/main/ssh/orcad-migration-source-fence.test.ts index 29c4867269b..a1bcf07f4d0 100644 --- a/src/main/ssh/orcad-migration-source-fence.test.ts +++ b/src/main/ssh/orcad-migration-source-fence.test.ts @@ -16,6 +16,8 @@ import { } from './orcad-migration-source-fence' import type { OrcadMigrationTerminalVerdict } from './orcad-migration-terminal-gate' import { SshTargetOrcadClaims } from './targets/ssh-target-orcad-claims' +import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' +import { MAX_ORCAD_MIGRATION_MANIFEST_BYTES } from '../../shared/orcad-migration/orcad-migration-manifest-validation' const TARGET: SshTarget = { id: 'ssh-prod', @@ -35,7 +37,7 @@ afterEach(async () => { } }) -function setup() { +function setup(displayName = 'App') { const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-migration-fence-')) directories.push(userDataPath) const dataFile = join(userDataPath, 'orca-data.json') @@ -44,7 +46,7 @@ function setup() { store.addRepo({ id: 'repo-1', path: '/srv/app', - displayName: 'App', + displayName, badgeColor: '#737373', addedAt: 1, kind: 'git', @@ -207,4 +209,21 @@ describe('migration source fence', () => { expect(listOrcadMigrationSourceCutovers(harness.userDataPath)).toEqual([]) flush.mockRestore() }) + + it('refuses, without a journal or fence, a manifest under the export cap that no request can carry', async () => { + const probe = setup() + const baseline = JSON.stringify( + createOrcadMigrationManifest(probe.store, probe.target()) + ).length + // Under the export cap, over the sealed-frame request budget: stage, state and abort all refuse. + const harness = setup('x'.repeat(MAX_ORCAD_MIGRATION_MANIFEST_BYTES - 200 - baseline)) + const manifestBytes = JSON.stringify( + createOrcadMigrationManifest(harness.store, harness.target()) + ).length + expect(manifestBytes).toBeLessThanOrEqual(MAX_ORCAD_MIGRATION_MANIFEST_BYTES) + + await expect(harness.fence()).rejects.toThrow(/\(orcad_migration_manifest_too_large\)$/) + expect(listOrcadMigrationSourceCutovers(harness.userDataPath)).toEqual([]) + expect(harness.target().orcadFence).toBeUndefined() + }) }) diff --git a/src/main/ssh/orcad-migration-source-fence.ts b/src/main/ssh/orcad-migration-source-fence.ts index 3c8cf4dc233..6e960f0b4e4 100644 --- a/src/main/ssh/orcad-migration-source-fence.ts +++ b/src/main/ssh/orcad-migration-source-fence.ts @@ -14,7 +14,10 @@ import { type OrcadMigrationSourceCutover } from '../../shared/orcad-migration/orcad-migration-source-cutover' import type { SshTarget } from '../../shared/ssh-types' -import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' +import { + assertOrcadMigrationRequestFits, + createOrcadMigrationManifest +} from './orcad-migration-manifest-export' import { findOrcadMigrationSourceCutoverForTarget, removeOrcadMigrationSourceCutover, @@ -147,6 +150,8 @@ export async function fenceOrcadMigrationSource(args: { { ...target, generation }, { migrationId: randomUUID(), destinationEnvironmentId: args.destinationEnvironmentId } ) + // Before the journal: a fence on a manifest no request can carry could never be released. + assertOrcadMigrationRequestFits({ manifest }) const cutover: OrcadMigrationSourceCutover = { version: ORCAD_MIGRATION_SOURCE_CUTOVER_VERSION, migrationId: manifest.migrationId,