From 672cadac5bbd0727563a64b6d6cd2a4c7d15d767 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 28 Aug 2026 20:17:41 +0800 Subject: [PATCH] fix(runtime-host): recover WSL roots after remount --- .../src/main/runtime-host-wsl-controller.ts | 1 + .../runtime-host-operator-command.test.ts | 15 ++++++ packages/cli/src/cli-core.ts | 11 +++- .../src/runtime-host-activation-command.ts | 8 ++- packages/cli/src/runtime-host-cli.ts | 16 ++++++ .../cli/src/runtime-host-connect-command.ts | 9 +++- .../cli/src/runtime-host-setup-command.ts | 24 ++++----- .../src/__tests__/wsl-environment.test.ts | 1 + .../src/client/wsl-environment.ts | 1 + .../src/operator/managed-deployment.ts | 10 ++++ .../src/__tests__/root-authority.test.ts | 38 +++++++++++++ packages/storage/src/root-authority.ts | 53 +++++++++++++++++++ 12 files changed, 170 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-wsl-controller.ts b/apps/desktop/src/main/runtime-host-wsl-controller.ts index 63bd2e0740..a7b92d44dd 100644 --- a/apps/desktop/src/main/runtime-host-wsl-controller.ts +++ b/apps/desktop/src/main/runtime-host-wsl-controller.ts @@ -185,6 +185,7 @@ function runtimeHostWslSetupCommand( 'desktop-client', '--lifecycle', 'on-demand', + '--repair-root-after-remount', ...(input.projectDirectoryRoots === undefined ? [] : input.projectDirectoryRoots.length === 0 diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index b11796ec66..e2236ddad4 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -49,6 +49,21 @@ describe('Runtime Host operator commands', () => { framed: true, }); assert.equal(parseRuntimeHostCommand(['activate', '--root-id', rootId]).kind, 'error'); + assert.deepEqual( + parseRuntimeHostCommand([ + 'connect', + '--framed', + '--root-id', + rootId, + '--repair-root-after-remount', + ]), + { + kind: 'runtime-host-managed-connect', + rootId, + framed: true, + repairRootAfterRemount: true, + }, + ); let output = ''; assert.equal( diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 95046512e5..8ee356dd78 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -248,11 +248,17 @@ export async function runMakaCli( const { runRuntimeHostManagedActivationCli } = await import( './runtime-host-activation-command.js' ); - return runRuntimeHostManagedActivationCli({ rootId: command.rootId }); + return runRuntimeHostManagedActivationCli({ + rootId: command.rootId, + ...(command.repairRootAfterRemount ? { repairRootAfterRemount: true } : {}), + }); } case 'runtime-host-managed-connect': { const { runRuntimeHostManagedConnectCli } = await import('./runtime-host-connect-command.js'); - return runRuntimeHostManagedConnectCli({ rootId: command.rootId }); + return runRuntimeHostManagedConnectCli({ + rootId: command.rootId, + ...(command.repairRootAfterRemount ? { repairRootAfterRemount: true } : {}), + }); } case 'run': { const { runRuntimeHostTextCli } = await import('./runtime-host-run-command.js'); @@ -403,6 +409,7 @@ export async function runMakaCli( lifecycle: command.lifecycle, deferPairingCommit: command.deferPairingCommit, bindPairingToClient: command.bindPairingToClient, + ...(command.repairRootAfterRemount ? { repairRootAfterRemount: true } : {}), ...(command.rootPath ? { rootPath: command.rootPath } : {}), ...(command.projectDirectoryRoots ? { projectDirectoryRoots: command.projectDirectoryRoots } diff --git a/packages/cli/src/runtime-host-activation-command.ts b/packages/cli/src/runtime-host-activation-command.ts index a10c85555d..cb47a48ec8 100644 --- a/packages/cli/src/runtime-host-activation-command.ts +++ b/packages/cli/src/runtime-host-activation-command.ts @@ -32,6 +32,7 @@ import { reconcileRuntimeHostUpdateOnActivation } from './runtime-host-update-re export interface RuntimeHostManagedActivationCliOptions { readonly rootId: string; + readonly repairRootAfterRemount?: true; } export function activateRuntimeHostManagedDeploymentWithReconciliation( @@ -54,7 +55,12 @@ export async function runRuntimeHostManagedActivationCli( try { const result = await ( overrides.activate ?? activateRuntimeHostManagedDeploymentWithReconciliation - )({ rootId: options.rootId }); + )({ + rootId: options.rootId, + ...(options.repairRootAfterRemount + ? { authority: { repairRootAfterRemount: true as const } } + : {}), + }); writeOutput(encodeRuntimeHostActivationFrame(result)); return 0; } catch (error) { diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index d48ef22728..cf2f884e1e 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -44,11 +44,13 @@ export type RuntimeHostCliCommand = kind: 'runtime-host-managed-activate'; rootId: string; framed: true; + repairRootAfterRemount?: true; } | { kind: 'runtime-host-managed-connect'; rootId: string; framed: true; + repairRootAfterRemount?: true; } | { kind: 'runtime-host-installed-update'; @@ -115,6 +117,7 @@ export type RuntimeHostCliCommand = lifecycle: 'supervised' | 'on_demand'; deferPairingCommit: boolean; bindPairingToClient?: true; + repairRootAfterRemount?: true; clientDataRoot?: string; rootPath?: string; projectDirectoryRoots?: { label: string; path: string }[]; @@ -325,6 +328,7 @@ function parseManagedRootFramedCommand( ): RuntimeHostCliCommand { let rootId: string | undefined; let framed = false; + let repairRootAfterRemount = false; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--framed') { @@ -339,6 +343,11 @@ function parseManagedRootFramedCommand( if (rootId === undefined) return error('--root-id requires a value'); continue; } + if (argument === '--repair-root-after-remount') { + if (repairRootAfterRemount) return error('Duplicate --repair-root-after-remount'); + repairRootAfterRemount = true; + continue; + } return error(`Unexpected runtime-host ${action} option: ${String(argument)}`); } if (!framed) return error(`runtime-host ${action} requires --framed`); @@ -349,6 +358,7 @@ function parseManagedRootFramedCommand( kind: action === 'activate' ? 'runtime-host-managed-activate' : 'runtime-host-managed-connect', rootId, framed: true, + ...(repairRootAfterRemount ? { repairRootAfterRemount: true } : {}), }; } @@ -544,6 +554,7 @@ function parseSetupCommand(argv: string[]): RuntimeHostCliCommand { let lifecycleProvided = false; let deferPairingCommit = false; let bindPairingToClient = false; + let repairRootAfterRemount = false; let clientDataRoot: string | undefined; let enableDirectPeer = false; const coordinationRelays: string[] = []; @@ -588,6 +599,10 @@ function parseSetupCommand(argv: string[]): RuntimeHostCliCommand { if (bindPairingToClient) return error('Duplicate --bind-pairing-to-client'); bindPairingToClient = true; }, + '--repair-root-after-remount': () => { + if (repairRootAfterRemount) return error('Duplicate --repair-root-after-remount'); + repairRootAfterRemount = true; + }, }, }); if ('kind' in options) return options; @@ -609,6 +624,7 @@ function parseSetupCommand(argv: string[]): RuntimeHostCliCommand { lifecycle, deferPairingCommit, ...(bindPairingToClient ? { bindPairingToClient: true } : {}), + ...(repairRootAfterRemount ? { repairRootAfterRemount: true } : {}), ...(clientDataRoot ? { clientDataRoot } : {}), ...(enableDirectPeer ? { directPeer: { coordinationRelays } } : {}), }; diff --git a/packages/cli/src/runtime-host-connect-command.ts b/packages/cli/src/runtime-host-connect-command.ts index e49070da4d..c7cb12fdab 100644 --- a/packages/cli/src/runtime-host-connect-command.ts +++ b/packages/cli/src/runtime-host-connect-command.ts @@ -23,7 +23,7 @@ import { openRuntimeHostManagedStdioBridge } from '@maka/runtime-host/client'; import { activateRuntimeHostManagedDeploymentWithReconciliation } from './runtime-host-activation-command.js'; export async function runRuntimeHostManagedConnectCli( - input: { readonly rootId: string }, + input: { readonly rootId: string; readonly repairRootAfterRemount?: true }, overrides: { readonly openBridge?: typeof openRuntimeHostManagedStdioBridge; readonly stdin?: Readable; @@ -36,7 +36,12 @@ export async function runRuntimeHostManagedConnectCli( let socket: Awaited> | undefined; try { socket = await (overrides.openBridge ?? openRuntimeHostManagedStdioBridge)( - { rootId: input.rootId }, + { + rootId: input.rootId, + ...(input.repairRootAfterRemount + ? { authority: { repairRootAfterRemount: true as const } } + : {}), + }, { activate: activateRuntimeHostManagedDeploymentWithReconciliation }, ); stdin.pipe(socket); diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index dfd556a1d0..d578e13707 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -76,7 +76,7 @@ import { RuntimeHostUpdateDiscoveryError, type RuntimeHostUpdateCandidate, } from './runtime-host-update-discovery.js'; -import { resolveStorageRoot } from '@maka/storage/root-authority'; +import { repairStorageRootAfterRemount, resolveStorageRoot } from '@maka/storage/root-authority'; import { createPlatformRuntimeHostServiceBackend, discoverRuntimeHostLifecycleProvider, @@ -131,6 +131,7 @@ export interface RuntimeHostSetupCliOptions { readonly lifecycle?: 'supervised' | 'on_demand'; readonly deferPairingCommit?: boolean; readonly bindPairingToClient?: boolean; + readonly repairRootAfterRemount?: true; readonly rootPath?: string; readonly projectDirectoryRoots?: readonly { readonly label: string; @@ -305,17 +306,16 @@ async function resolveRuntimeHostSetupRootId(options: RuntimeHostSetupCliOptions throw error; } } - return ( - await resolveStorageRoot({ - path: resolve( - options.rootPath ?? - legacyRootPath ?? - options.expectedTarget?.rootPath ?? - options.defaultRootPath, - ), - kind: 'interactive', - }) - ).rootId; + const path = resolve( + options.rootPath ?? + legacyRootPath ?? + options.expectedTarget?.rootPath ?? + options.defaultRootPath, + ); + if (options.repairRootAfterRemount) { + await repairStorageRootAfterRemount({ path, kind: 'interactive' }); + } + return (await resolveStorageRoot({ path, kind: 'interactive' })).rootId; } async function runRuntimeHostSetupLocked( diff --git a/packages/runtime-host/src/__tests__/wsl-environment.test.ts b/packages/runtime-host/src/__tests__/wsl-environment.test.ts index 6a36d804a4..722be8d464 100644 --- a/packages/runtime-host/src/__tests__/wsl-environment.test.ts +++ b/packages/runtime-host/src/__tests__/wsl-environment.test.ts @@ -54,6 +54,7 @@ test('passes WSL target values as literal argv to the absolute operator', async '--framed', '--root-id', 'a'.repeat(64), + '--repair-root-after-remount', ], }); }); diff --git a/packages/runtime-host/src/client/wsl-environment.ts b/packages/runtime-host/src/client/wsl-environment.ts index b93c41ef01..56b9a01f03 100644 --- a/packages/runtime-host/src/client/wsl-environment.ts +++ b/packages/runtime-host/src/client/wsl-environment.ts @@ -93,6 +93,7 @@ export async function connectRuntimeHostWslEnvironment( '--framed', '--root-id', rootId, + '--repair-root-after-remount', ]); const resource = new WslProcessByteStream(child); const transport = new FramedByteStreamTransport(resource); diff --git a/packages/runtime-host/src/operator/managed-deployment.ts b/packages/runtime-host/src/operator/managed-deployment.ts index 777de4ea7f..9aaa6d297e 100644 --- a/packages/runtime-host/src/operator/managed-deployment.ts +++ b/packages/runtime-host/src/operator/managed-deployment.ts @@ -26,6 +26,7 @@ import { type StateRootOwner, type StorageRootCapability, assertStorageRootLease, + repairStorageRootAfterRemount, resolveExistingStorageRoot, tryAcquireStateRootOwner, } from '@maka/storage/root-authority'; @@ -321,6 +322,8 @@ export interface RuntimeHostManagedDeploymentTransitionInput { } export interface RuntimeHostManagedDeploymentAuthorityOptions { + /** Explicitly accept a device-only root identity change at a known remount boundary. */ + readonly repairRootAfterRemount?: true; /** Test-only or embedding override. Production uses the account-local durable default. */ readonly authorityRoot?: string; readonly homeDir?: string; @@ -565,6 +568,13 @@ export async function resolveRuntimeHostManagedDeploymentAuthority( 'The Runtime Host managed deployment record has an invalid Root identity', ); } + if (options.repairRootAfterRemount) { + await repairStorageRootAfterRemount({ + path: initial.root.path, + kind: 'interactive', + expectedRootId: rootId, + }); + } const capability = await resolveExistingStorageRoot({ path: initial.root.path, kind: 'interactive', diff --git a/packages/storage/src/__tests__/root-authority.test.ts b/packages/storage/src/__tests__/root-authority.test.ts index 450b5e8135..cc6c70454a 100644 --- a/packages/storage/src/__tests__/root-authority.test.ts +++ b/packages/storage/src/__tests__/root-authority.test.ts @@ -45,6 +45,7 @@ import { prepareArtifactWriterBootstrapAuthority, prepareStorageRootControlDirectory, prepareStorageRootIdentityRepair, + repairStorageRootAfterRemount, repairStorageRootIdentity, resolveExistingStorageRoot, resolveExistingStorageRootControlDirectory, @@ -215,6 +216,43 @@ describe('storage root authority', () => { }); }); + test('repairs a remounted device but refuses a different directory inode', async () => { + await withRoots(async ({ base, root }) => { + const initialized = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootIdentity: { dev: string; ino: string }; + }; + marker.rootIdentity.dev = (BigInt(marker.rootIdentity.dev) + 1n).toString(); + await writeFile(markerPath, `${JSON.stringify(marker)}\n`); + + await repairStorageRootAfterRemount({ + path: root, + kind: 'interactive', + expectedRootId: initialized.rootId, + }); + assert.equal( + (await resolveStorageRoot({ path: root, kind: 'interactive' })).rootId, + initialized.rootId, + ); + + const replacement = join(base, 'replacement'); + await mkdir(replacement); + const replacementMarkerPath = join(replacement, STORAGE_ROOT_MARKER_FILE); + const repairedMarker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootIdentity: { dev: string; ino: string }; + }; + const replacementStat = await lstat(replacement, { bigint: true }); + repairedMarker.rootIdentity.ino = (replacementStat.ino + 1n).toString(); + await writeFile(replacementMarkerPath, `${JSON.stringify(repairedMarker)}\n`); + await assert.rejects( + () => repairStorageRootAfterRemount({ path: replacement, kind: 'interactive' }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_identity_changed', + ); + }); + }); + test('rejects a prepared repair when its marker changes before commit', async () => { await withRoots(async ({ root }) => { await resolveStorageRoot({ path: root, kind: 'interactive' }); diff --git a/packages/storage/src/root-authority.ts b/packages/storage/src/root-authority.ts index 0b62119449..b7ed6773b4 100644 --- a/packages/storage/src/root-authority.ts +++ b/packages/storage/src/root-authority.ts @@ -95,6 +95,11 @@ export interface ResolveExistingStorageRootInput export type AdoptStorageRootOnImportInput = ResolveExistingStorageRootInput; +export interface RepairStorageRootAfterRemountInput + extends ResolveStorageRootInput { + expectedRootId?: string; +} + export interface StorageRootIdentityRepairCandidate { readonly kind: K; readonly canonicalPath: string; @@ -360,6 +365,54 @@ export async function prepareStorageRootIdentityRepair( + input: RepairStorageRootAfterRemountInput, +): Promise | undefined> { + let candidate: StorageRootIdentityRepairCandidate | undefined; + try { + candidate = await prepareStorageRootIdentityRepair(input); + } catch (error) { + if ( + error instanceof StorageRootAuthorityError && + (error.code === 'root_not_found' || error.code === 'root_unmarked') + ) { + return undefined; + } + throw error; + } + if (!candidate) return undefined; + const record = storageRootIdentityRepairs.get(candidate) as + | StorageRootIdentityRepairRecord + | undefined; + if (!record) { + throw new StorageRootAuthorityError( + 'invalid_repair', + 'Expected a prepared storage root identity repair', + ); + } + if (input.expectedRootId !== undefined && record.rootId !== input.expectedRootId) { + storageRootIdentityRepairs.delete(candidate); + throw new StorageRootAuthorityError( + 'root_identity_changed', + `Remounted storage root does not match the expected root: ${record.canonicalPath}`, + ); + } + if (record.marker.rootIdentity.ino !== record.identity.ino.toString()) { + storageRootIdentityRepairs.delete(candidate); + throw new StorageRootAuthorityError( + 'root_identity_changed', + `Storage root directory changed across remount: ${record.canonicalPath}`, + ); + } + return repairStorageRootIdentity(candidate); +} + /** * Explicit recovery boundary for a root whose host-local filesystem identity * is stale. Callers must obtain user intent for this exact candidate first.