Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/main/runtime-host-wsl-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ function runtimeHostWslSetupCommand(
'desktop-client',
'--lifecycle',
'on-demand',
'--repair-root-after-remount',
...(input.projectDirectoryRoots === undefined
? []
: input.projectDirectoryRoots.length === 0
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/__tests__/runtime-host-operator-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 }
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/runtime-host-activation-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { reconcileRuntimeHostUpdateOnActivation } from './runtime-host-update-re

export interface RuntimeHostManagedActivationCliOptions {
readonly rootId: string;
readonly repairRootAfterRemount?: true;
}

export function activateRuntimeHostManagedDeploymentWithReconciliation(
Expand All @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/runtime-host-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }[];
Expand Down Expand Up @@ -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') {
Expand All @@ -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`);
Expand All @@ -349,6 +358,7 @@ function parseManagedRootFramedCommand(
kind: action === 'activate' ? 'runtime-host-managed-activate' : 'runtime-host-managed-connect',
rootId,
framed: true,
...(repairRootAfterRemount ? { repairRootAfterRemount: true } : {}),
};
}

Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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;
Expand All @@ -609,6 +624,7 @@ function parseSetupCommand(argv: string[]): RuntimeHostCliCommand {
lifecycle,
deferPairingCommit,
...(bindPairingToClient ? { bindPairingToClient: true } : {}),
...(repairRootAfterRemount ? { repairRootAfterRemount: true } : {}),
...(clientDataRoot ? { clientDataRoot } : {}),
...(enableDirectPeer ? { directPeer: { coordinationRelays } } : {}),
};
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/runtime-host-connect-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,7 +36,12 @@ export async function runRuntimeHostManagedConnectCli(
let socket: Awaited<ReturnType<typeof openRuntimeHostManagedStdioBridge>> | 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);
Expand Down
24 changes: 12 additions & 12 deletions packages/cli/src/runtime-host-setup-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
});
});
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-host/src/client/wsl-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions packages/runtime-host/src/operator/managed-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type StateRootOwner,
type StorageRootCapability,
assertStorageRootLease,
repairStorageRootAfterRemount,
resolveExistingStorageRoot,
tryAcquireStateRootOwner,
} from '@maka/storage/root-authority';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
38 changes: 38 additions & 0 deletions packages/storage/src/__tests__/root-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
prepareArtifactWriterBootstrapAuthority,
prepareStorageRootControlDirectory,
prepareStorageRootIdentityRepair,
repairStorageRootAfterRemount,
repairStorageRootIdentity,
resolveExistingStorageRoot,
resolveExistingStorageRootControlDirectory,
Expand Down Expand Up @@ -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' });
Expand Down
53 changes: 53 additions & 0 deletions packages/storage/src/root-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ export interface ResolveExistingStorageRootInput<K extends StorageRootKind>
export type AdoptStorageRootOnImportInput<K extends StorageRootKind> =
ResolveExistingStorageRootInput<K>;

export interface RepairStorageRootAfterRemountInput<K extends StorageRootKind>
extends ResolveStorageRootInput<K> {
expectedRootId?: string;
}

export interface StorageRootIdentityRepairCandidate<K extends StorageRootKind = StorageRootKind> {
readonly kind: K;
readonly canonicalPath: string;
Expand Down Expand Up @@ -360,6 +365,54 @@ export async function prepareStorageRootIdentityRepair<K extends StorageRootKind
);
}

/**
* Repairs only the mount-local portion of a root identity. This is for callers
* that already know their execution environment remounted the same filesystem:
* the inode must stay unchanged, and an expected durable root id may still pin
* the repair to an existing Client binding.
*/
export async function repairStorageRootAfterRemount<K extends StorageRootKind>(
input: RepairStorageRootAfterRemountInput<K>,
): Promise<StorageRootCapability<K> | undefined> {
let candidate: StorageRootIdentityRepairCandidate<K> | 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<K>
| 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.
Expand Down
Loading