diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts index 8b57c82549..5a08954585 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts @@ -133,6 +133,75 @@ test('local setup forwards the exact development archive evidence', async (t) => assert.equal(environment?.[RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV], integrity); }); +test('local setup scratch cleanup cannot replace its framed outcome', async (t) => { + const frames = [ + { + schemaVersion: 1 as const, + sequence: 0, + kind: 'complete' as const, + version: '0.2.0', + serviceId: 'b'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', + operator: OPERATOR, + rootPath: '/tmp/maka/root', + rootId: 'a'.repeat(64), + endpoint: 'ws://127.0.0.1:7443/runtime-host', + credentialId: 'credential-1', + credential: 'secret-access-token', + }, + { + schemaVersion: 1 as const, + sequence: 0, + kind: 'error' as const, + error: { code: 'setup_failed', message: 'primary setup failure' }, + }, + ]; + let invocation = 0; + let cleanupCount = 0; + const spawnProcess = (() => { + const child = new EventEmitter() as ReturnType; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const frame = frames[invocation++]; + Object.assign(child, { pid: 1234, stdout, stderr, kill: () => true }); + process.nextTick(() => { + stdout.end(encodeRuntimeHostSetupFrame(frame)); + stderr.end(); + child.emit('close', frame.kind === 'complete' ? 0 : 1, null); + }); + return child; + }) as typeof spawn; + const operator = createDesktopRuntimeHostLocalOperator({ + environment: { PATH: process.env.PATH }, + spawnProcess, + removeSetupWorkingDirectory: async (path) => { + cleanupCount += 1; + await rm(path, { recursive: true, force: true }); + throw new Error('scratch cleanup failed'); + }, + }); + t.after(() => operator.close()); + const setup = { + setupPackage: { kind: 'npm' as const, specifier: 'maka-agent@0.2.0' }, + clientDataRoot: '/tmp/maka/client', + rootPath: '/tmp/maka/root', + principalId: 'desktop-owner:pairing', + expectedTarget: { + serviceId: 'b'.repeat(64), + rootPath: '/tmp/maka/root', + rootId: 'a'.repeat(64), + }, + }; + + const complete = await operator.runSetup(setup, () => undefined); + assert.equal(complete.kind, 'complete'); + await assert.rejects( + operator.runSetup(setup, () => undefined), + /primary setup failure/u, + ); + assert.equal(cleanupCount, 2); +}); + test('Windows npm discovery cannot outlive setup cancellation', async (t) => { const originalPlatform = process.platform; const fixtureRoot = await mkdtemp(join(tmpdir(), 'maka-windows-npm-lookup-')); diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index 4b00e73885..1ade55cedc 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -62,6 +62,8 @@ import { const SETUP_TIMEOUT_MS = 10 * 60_000; const SETUP_FRAME_PENDING_MAX = 20 * 1024; const STDERR_MAX_BYTES = 64 * 1024; +const SETUP_CLEANUP_MAX_RETRIES = 10; +const SETUP_CLEANUP_RETRY_DELAY_MS = 100; const WINDOWS_NPM_RESOLUTION_SCRIPT = String.raw` const { statSync } = require('node:fs'); const path = require('node:path').win32; @@ -199,6 +201,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { readonly spawnProcess?: typeof spawn; readonly setupTimeoutMs?: number; readonly terminateProcess?: typeof terminateChildProcessTree; + readonly removeSetupWorkingDirectory?: (path: string) => Promise; } = {}): { runSetup( setup: DesktopRuntimeHostLocalSetupInput, @@ -267,6 +270,15 @@ export function createDesktopRuntimeHostLocalOperator(input: { let closed = false; const closing = new AbortController(); const terminate = input.terminateProcess ?? terminateChildProcessTree; + const removeSetupWorkingDirectory = + input.removeSetupWorkingDirectory ?? + ((path: string) => + rm(path, { + recursive: true, + force: true, + maxRetries: SETUP_CLEANUP_MAX_RETRIES, + retryDelay: SETUP_CLEANUP_RETRY_DELAY_MS, + })); return { async runSetup(setup, onProgress) { @@ -302,7 +314,12 @@ export function createDesktopRuntimeHostLocalOperator(input: { active, }); } finally { - await rm(workingDirectory, { recursive: true, force: true }); + try { + await removeSetupWorkingDirectory(workingDirectory); + } catch { + // The private scratch directory is not part of the setup transaction. + // Its cleanup must not replace the operator's framed result or error. + } } }, runPeer(command) { diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index a8818d2783..bf8c8bc2c2 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -54,12 +54,19 @@ import { acknowledgeRuntimeHostManagedDeploymentCleanup, assertRuntimeHostManagedOperatorDeployment, convergeRuntimeHostManagedOperator, + convergeRuntimeHostManagedWindowsTaskLauncher, prepareRuntimeHostManagedPackageDeployment, pruneRuntimeHostManagedPackages, readRuntimeHostManagedDeploymentCleanupReceipt, resolveRuntimeHostManagedControlRoot, resolveRuntimeHostManagedDeploymentRoot, + verifyRuntimeHostManagedWindowsTaskLauncher, } from '../runtime-host-managed-deployment.js'; +import { + resolvePackagedRuntimeHostWindowsTaskLauncherPath, + resolveRuntimeHostWindowsTaskLauncherPath, + runtimeHostManagedWindowsTaskLauncherPath, +} from '../runtime-host-windows-task-launcher-artifact.js'; import { runRuntimeHostSetupCli } from '../runtime-host-setup-command.js'; import { RuntimeHostAccessUnavailableError } from '../runtime-host-access-command.js'; import { replaceRuntimeHostLifecycle } from '../runtime-host-lifecycle-transaction.js'; @@ -977,6 +984,75 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' assert.deepEqual(signalExit, { code: null, signal: 'SIGTERM' }); }); +test('managed Windows task launcher is projected to a stable deployment path', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-windows-launcher-')); + t.after(() => rm(base, { recursive: true, force: true })); + const sourcePackageRoot = await createReleasePackage(base, '0.2.0'); + const sourceLauncher = join( + sourcePackageRoot, + 'native', + 'runtime-host-windows-task-launcher', + 'prebuilds', + 'win32-x64', + 'maka-runtime-host-task-launcher.exe', + ); + await mkdir(dirname(sourceLauncher), { recursive: true }); + await writeFile(sourceLauncher, 'launcher-v1'); + const serviceId = 'a'.repeat(64); + const deployment = await prepareRuntimeHostManagedPackageDeployment( + { + serviceId, + clientDataRoot: join(base, 'client'), + sourcePackageRoot, + version: '0.2.0', + packageIntegrity: PACKAGE_INTEGRITY, + }, + { + env: { XDG_DATA_HOME: join(base, 'data') }, + homeDir: join(base, 'home'), + platform: 'linux', + }, + ); + const config: RuntimeHostManagedDeploymentConfig = { + schemaVersion: 1, + state: 'active', + deploymentId: '00000000-0000-4000-8000-000000000001', + configRevision: 1, + deploymentRoot: deployment.root, + root: { id: serviceId, path: join(base, 'state') }, + projectDirectoryRoots: [], + launch: { + kind: 'exact_package', + nodePath: process.execPath, + package: { kind: 'npm_registry', version: '0.2.0', integrity: PACKAGE_INTEGRITY }, + }, + listeners: { localIpc: true }, + lifecycle: { mode: 'supervised', provider: 'windows_task', availability: 'session' }, + reconciliation: { trigger: 'scheduled', provider: 'windows_task_timer' }, + }; + + await convergeRuntimeHostManagedWindowsTaskLauncher(config); + const projected = runtimeHostManagedWindowsTaskLauncherPath( + deployment.root, + Buffer.from('launcher-v1'), + ); + assert.equal(await readFile(projected, 'utf8'), 'launcher-v1'); + assert.equal(await resolveRuntimeHostWindowsTaskLauncherPath(deployment.cliPath), projected); + + const packaged = await resolvePackagedRuntimeHostWindowsTaskLauncherPath(deployment.cliPath); + await writeFile(packaged, 'launcher-v2'); + await assert.rejects( + verifyRuntimeHostManagedWindowsTaskLauncher(config), + /does not match its deployment/u, + ); + await convergeRuntimeHostManagedWindowsTaskLauncher(config); + await verifyRuntimeHostManagedWindowsTaskLauncher(config); + assert.notEqual( + runtimeHostManagedWindowsTaskLauncherPath(deployment.root, Buffer.from('launcher-v2')), + projected, + ); +}); + async function createReleasePackage(base: string, version: string): Promise { const root = join(base, `source-package-${version}`); await mkdir(join(root, 'dist'), { recursive: true }); diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index 30e46b997a..4f383449a9 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -41,6 +41,11 @@ import { type RuntimeHostManagedDeploymentAuthorityOptions, type RuntimeHostManagedDeploymentConfig, } from '@maka/runtime-host/operator'; +import { + readRuntimeHostWindowsTaskLauncher, + resolvePackagedRuntimeHostWindowsTaskLauncherPath, + runtimeHostManagedWindowsTaskLauncherPath, +} from './runtime-host-windows-task-launcher-artifact.js'; export { RuntimeHostPackageDeploymentError as RuntimeHostManagedDeploymentError } from './runtime-host-package-deployment.js'; @@ -63,7 +68,6 @@ interface RuntimeHostManagedDeploymentCleanupReceipt { } const CLEANUP_RECEIPT_FILE = 'cleanup-approved.json'; - export function resolveRuntimeHostManagedPackageCliPath( deploymentRoot: string, version: string, @@ -641,15 +645,15 @@ async function writeOperatorLauncher( managedRootId, deploymentId, ); - await writeStableOperator(path, contents); + await writeStableArtifact(path, contents); } -async function writeStableOperator(path: string, contents: string): Promise { +async function writeStableArtifact(path: string, contents: string | Uint8Array): Promise { const temporaryPath = `${path}.${randomUUID()}.tmp`; try { const file = await open(temporaryPath, 'wx', 0o700); try { - await file.writeFile(contents, 'utf8'); + await file.writeFile(contents); await file.sync(); } finally { await file.close(); @@ -735,6 +739,9 @@ export async function convergeRuntimeHostManagedOperator( desired.root.id, desired.deploymentId, ); + if (process.platform === 'win32') { + await convergeRuntimeHostManagedWindowsTaskLauncher(desired); + } await forwardLegacyOperatorIfPresent( deployment.deploymentRoot, desired.launch.nodePath, @@ -761,7 +768,7 @@ async function forwardLegacyOperatorIfPresent( }, ); if (exists) { - await writeStableOperator(path, legacyOperatorLauncherContents(nodePath, modulePath)); + await writeStableArtifact(path, legacyOperatorLauncherContents(nodePath, modulePath)); } } @@ -811,6 +818,9 @@ export async function verifyRuntimeHostManagedOperator( cause: error, }); }); + if (process.platform === 'win32') { + await verifyRuntimeHostManagedWindowsTaskLauncher(config, { allowAbsent: true }); + } const legacyOperatorPath = join(config.deploymentRoot, 'operator'); const legacyExpected = legacyOperatorLauncherContents(config.launch.nodePath, operatorPath); const legacyExists = await access(legacyOperatorPath, constants.F_OK).then( @@ -839,6 +849,61 @@ export async function verifyRuntimeHostManagedOperator( } } +export async function convergeRuntimeHostManagedWindowsTaskLauncher( + config: RuntimeHostManagedDeploymentConfig, +): Promise { + const layout = resolveRuntimeHostNpmDeploymentLayout( + config.deploymentRoot, + config.launch.package.integrity, + ); + const sourcePath = await resolvePackagedRuntimeHostWindowsTaskLauncherPath(layout.cliPath); + const expected = await readRuntimeHostWindowsTaskLauncher(sourcePath); + const projectedPath = runtimeHostManagedWindowsTaskLauncherPath(config.deploymentRoot, expected); + const projectedExists = await access(projectedPath, constants.F_OK).then( + () => true, + (error: unknown) => { + if (isNodeError(error, 'ENOENT')) return false; + throw error; + }, + ); + if (projectedExists) { + const observed = await readRuntimeHostWindowsTaskLauncher(projectedPath); + if (!observed.equals(expected)) { + throw new Error('The managed Runtime Host Windows task launcher is invalid'); + } + return; + } + await writeStableArtifact(projectedPath, expected); +} + +export async function verifyRuntimeHostManagedWindowsTaskLauncher( + config: RuntimeHostManagedDeploymentConfig, + options: { readonly allowAbsent?: boolean } = {}, +): Promise { + const layout = resolveRuntimeHostNpmDeploymentLayout( + config.deploymentRoot, + config.launch.package.integrity, + ); + const sourcePath = await resolvePackagedRuntimeHostWindowsTaskLauncherPath(layout.cliPath); + const expected = await readRuntimeHostWindowsTaskLauncher(sourcePath); + const projectedPath = runtimeHostManagedWindowsTaskLauncherPath(config.deploymentRoot, expected); + const projectedExists = await access(projectedPath, constants.F_OK).then( + () => true, + (error: unknown) => { + if (isNodeError(error, 'ENOENT')) return false; + throw error; + }, + ); + if (!projectedExists) { + if (options.allowAbsent) return; + throw new Error('The managed Runtime Host Windows task launcher does not match its deployment'); + } + const observed = await readRuntimeHostWindowsTaskLauncher(projectedPath); + if (!observed.equals(expected)) { + throw new Error('The managed Runtime Host Windows task launcher does not match its deployment'); + } +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/cli/src/runtime-host-package-deployment.ts b/packages/cli/src/runtime-host-package-deployment.ts index 18d1968b41..ff2f7a7cf8 100644 --- a/packages/cli/src/runtime-host-package-deployment.ts +++ b/packages/cli/src/runtime-host-package-deployment.ts @@ -369,6 +369,7 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); await rm(path, { recursive: true, force: true, maxRetries: 100, retryDelay: 100 }); })().catch(() => { process.exitCode = 1; });`; const cleanup = spawn(process.execPath, ['-e', script, path, String(process.pid)], { + cwd: dirname(process.execPath), detached: true, stdio: 'ignore', windowsHide: true, diff --git a/packages/cli/src/runtime-host-windows-task-launcher-artifact.ts b/packages/cli/src/runtime-host-windows-task-launcher-artifact.ts index 5045017dd3..d6adcb8ebf 100644 --- a/packages/cli/src/runtime-host-windows-task-launcher-artifact.ts +++ b/packages/cli/src/runtime-host-windows-task-launcher-artifact.ts @@ -17,12 +17,31 @@ * under the License. */ +import { createHash } from 'node:crypto'; import { access, realpath } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; +import { readStableBoundedFile } from '@maka/storage/stable-storage'; const LAUNCHER_FILE = 'maka-runtime-host-task-launcher.exe'; +const MANAGED_LAUNCHER_PREFIX = 'maka-runtime-host-task-launcher-'; +const WINDOWS_TASK_LAUNCHER_MAX_BYTES = 1024 * 1024; export async function resolveRuntimeHostWindowsTaskLauncherPath(cliPath: string): Promise { + const packageRoot = dirname(dirname(await realpath(cliPath))); + const packaged = await resolvePackagedRuntimeHostWindowsTaskLauncherPath(cliPath); + const contents = await readRuntimeHostWindowsTaskLauncher(packaged); + const projected = projectedLauncherPath(packageRoot, contents); + if (projected) { + const observed = await readRuntimeHostWindowsTaskLauncher(projected).catch(() => undefined); + if (observed?.equals(contents)) return realpath(projected); + } + + return packaged; +} + +export async function resolvePackagedRuntimeHostWindowsTaskLauncherPath( + cliPath: string, +): Promise { const packageRoot = dirname(dirname(await realpath(cliPath))); const packaged = join( packageRoot, @@ -51,6 +70,29 @@ export async function resolveRuntimeHostWindowsTaskLauncherPath(cliPath: string) throw new Error('Maka does not include the Windows Runtime Host task launcher'); } +export function runtimeHostManagedWindowsTaskLauncherPath( + deploymentRoot: string, + contents: Uint8Array, +): string { + const digest = createHash('sha256').update(contents).digest('hex'); + return join(deploymentRoot, `${MANAGED_LAUNCHER_PREFIX}${digest}.exe`); +} + +export function readRuntimeHostWindowsTaskLauncher(path: string): Promise { + return readStableBoundedFile({ + path, + maxBytes: WINDOWS_TASK_LAUNCHER_MAX_BYTES, + invalidFile: () => new Error('The managed Runtime Host Windows task launcher is invalid'), + }); +} + +function projectedLauncherPath(packageRoot: string, contents: Uint8Array): string | undefined { + const versionsRoot = dirname(packageRoot); + return basename(versionsRoot) === 'versions' + ? runtimeHostManagedWindowsTaskLauncherPath(dirname(versionsRoot), contents) + : undefined; +} + async function isReadable(path: string): Promise { try { await access(path);