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 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-23 04:09:42 -07:00
co-authored by Claude
parent 332bbb1419
commit 19f14237ff
2 changed files with 27 additions and 4 deletions
@@ -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<string, unknown> = { ...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'] })
@@ -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<OrcadMigrationManifest, 'manifestSha256'> = 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,