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 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-19 12:32:06 -07:00
committed by GitHub
co-authored by Claude
parent 43abd59f3d
commit a75748ae32
6 changed files with 81 additions and 20 deletions
+2 -17
View File
@@ -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<OrcadCatalogMigrationOperation, string> = {
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) =>
@@ -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(
@@ -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
blockers: plan.blockers
}
}
if (!plan.resumes) {
// Before the journal: a delta no request can carry would pin the host's fence for good.
assertOrcadMigrationRequestFits({ manifest: plan.manifest })
}
const terminals = await assessOrcadMigrationTerminals(store, target.id, args.censusHost)
if (terminals.verdict !== 'exited' && terminals.verdict !== 'bridged') {
return refuse('orcad_migration_terminals', terminals.reason)
@@ -17,6 +17,7 @@ import type {
OrcadMigrationManifest
} from '../../shared/orcad-migration/orcad-migration-manifest'
import type { SshTarget } from '../../shared/ssh-types'
import { REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES } from '../../shared/remote-runtime-memory-limits'
import type { Store } from '../persistence'
import { collectOrcadMigrationSourceCatalog } from '../persistence/migrating-orcad-catalog/orcad-source-catalog'
import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest'
@@ -104,6 +105,32 @@ export function createOrcadMigrationManifest(
const ORCAD_MIGRATION_MANIFEST_TOO_LARGE = 'orcad_migration_manifest_too_large'
// Request id, device token, method name and JSON framing around the params.
const REQUEST_ENVELOPE_RESERVE_BYTES = 1024
/** The largest catalog-request params that still fit the host's sealed inbound frame. */
export const ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES =
REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES - REQUEST_ENVELOPE_RESERVE_BYTES
/**
* Throws the user-facing refusal when a catalog request would overflow the host's sealed frame;
* the host would answer by closing the socket. Every request carries the whole manifest, so a
* fence must not be taken on one that fails this: its stage, state and abort would all refuse.
*/
export function assertOrcadMigrationRequestFits(params: {
manifest: OrcadMigrationManifest
}): void {
if (
Buffer.byteLength(JSON.stringify(params), 'utf8') > 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,
@@ -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()
})
})
+6 -1
View File
@@ -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,