From 19f14237ff0da210394fb8eb79372b5c5e6ee349 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 23 Oct 2026 04:09:42 -0700 Subject: [PATCH] fix(wsl-mover): sign the manifest as the host parses it The likelier cause of awin's digest mismatch: the signature covered the exported objects, but the manifest parser keeps only the fields it knows, so a session field it drops (one a sleep can write) left the journal and the host hashing something else. Sign the parsed form, and warn when the parser dropped anything, since such a field does not move. Co-Authored-By: Claude --- .../wsl-move-manifest-export.test.ts | 14 ++++++++++++++ .../wsl-move-manifest-export.ts | 17 +++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts index 761a917e27c..4db314a5272 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts @@ -391,6 +391,20 @@ describe('exportWslMoveManifest', () => { }) }) + it('signs the session as the host parses it, even with a field the parser drops', () => { + // The awin run: a move right after sleeping terminals reset with a digest mismatch, because the + // signature covered session fields the manifest parser does not keep. + const state = sourceState() + const sleptTab: Record = { ...state.workspaceSession.tabsByWorktree[WORKTREE]![0] } + sleptTab.fieldNoParserKeeps = 1 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an extra field on a real tab. + state.workspaceSession.tabsByWorktree[WORKTREE] = [sleptTab as unknown as TerminalTab] + const { manifest } = exportWslMoveManifest(storeFor(state), REQUEST) + expect(() => + assertOrcadMigrationManifestDigest(JSON.parse(JSON.stringify(manifest))) + ).not.toThrow() + }) + it('refuses to sign a scope naming a project that is no longer here', () => { expect(() => exportWslMoveManifest(storeFor(sourceState()), { ...REQUEST, repoIds: ['app', 'gone'] }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts index 85c59f7fb1c..db98ee73dea 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts @@ -74,6 +74,8 @@ export class WslMoveScopeChangedError extends Error { } } +const UNSIGNED_DIGEST = '0'.repeat(64) + export type WslMoveManifestExportStore = Pick< Store, | 'getRepos' @@ -155,8 +157,9 @@ export function exportWslMoveManifest( { ...catalog, ...(hasDormantState(dormantState) ? { dormantState } : {}) }, rewriter ) - // Sign the JSON the journal and the host read back: an in-memory Date (a row added this session) - // canonicalizes to "{}" here but to its string there, and the host refuses the digest. + // Sign what the journal and the host read back: the parsed form (the parser keeps only the fields + // it knows, so a session field it drops would break the digest) of the JSON (an in-memory value + // with toJSON serializes differently), never the live objects. const unsigned: Omit = JSON.parse( JSON.stringify({ version: ORCAD_MIGRATION_MANIFEST_VERSION, @@ -167,10 +170,16 @@ export function exportWslMoveManifest( destinationEnvironmentId: request.destinationEnvironmentId }) ) - const manifest = parseOrcadMigrationManifest({ + const { manifestSha256: _unsigned, ...normalized } = parseOrcadMigrationManifest({ ...unsigned, - manifestSha256: computeOrcadMigrationManifestSha256(unsigned) + manifestSha256: UNSIGNED_DIGEST }) + const manifestSha256 = computeOrcadMigrationManifestSha256(normalized) + if (manifestSha256 !== computeOrcadMigrationManifestSha256(unsigned)) { + // Not refused (the parsed form is what moves), but a field the parser drops does not move. + console.warn('[wsl-move] The manifest parser dropped fields of the exported state.') + } + const manifest: OrcadMigrationManifest = { ...normalized, manifestSha256 } const worktreeIds = scopedWorktreeIds(repos, { ...catalog, dormantState }) const workspaceKeys = [ ...worktreeIds,