From 6914a3b6927f7c4ca8a6e787fb70fabc22588cc7 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 26 Aug 2026 21:20:54 +0800 Subject: [PATCH 1/6] refactor(cli): share verified registry update artifacts --- .../runtime-host-update-package.test.ts | 62 ++++++- .../cli/src/runtime-host-cli-installation.ts | 6 +- .../cli/src/runtime-host-registry-update.ts | 164 +++++++++++++++++ .../cli/src/runtime-host-update-discovery.ts | 146 ++------------- .../cli/src/runtime-host-update-package.ts | 174 +++++++++++++----- packages/runtime-host/package.json | 1 + 6 files changed, 362 insertions(+), 191 deletions(-) create mode 100644 packages/cli/src/runtime-host-registry-update.ts diff --git a/packages/cli/src/__tests__/runtime-host-update-package.test.ts b/packages/cli/src/__tests__/runtime-host-update-package.test.ts index 241e05a2ba..7e604494ac 100644 --- a/packages/cli/src/__tests__/runtime-host-update-package.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-package.test.ts @@ -19,18 +19,78 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; -import { mkdir, stat, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; import { RuntimeHostUpdatePackageError, + withRuntimeHostRegistryUpdateArtifact, withRuntimeHostRegistryUpdatePackage, + withVerifiedRuntimeHostUpdateArchive, } from '../runtime-host-update-package.js'; const ARCHIVE = Buffer.from('verified release archive'); const INTEGRITY = `sha512-${createHash('sha512').update(ARCHIVE).digest('base64')}`; describe('managed Runtime Host update package acquisition', () => { + it('keeps the verified archive available for an installation-owner finalizer', async () => { + const candidate = { + kind: 'npm_registry' as const, + version: '2.0.0', + integrity: INTEGRITY, + }; + let archivePath = ''; + await withRuntimeHostRegistryUpdateArtifact( + candidate, + async (artifact) => { + archivePath = artifact.archivePath; + assert.equal((await stat(archivePath)).isFile(), true); + }, + async (args) => { + if (args[0] === 'pack') { + const destination = args[args.indexOf('--pack-destination') + 1]!; + await writeFile(join(destination, 'maka-agent-2.0.0.tgz'), ARCHIVE); + return 0; + } + const prefix = args[args.indexOf('--prefix') + 1]!; + const root = join(prefix, 'node_modules', 'maka-agent'); + await Promise.all([ + mkdir(join(root, 'dist'), { recursive: true }), + mkdir(join(root, 'node_modules', '@maka', 'runtime-host'), { recursive: true }), + ]); + await Promise.all([ + writeFile( + join(root, 'package.json'), + JSON.stringify({ name: 'maka-agent', version: '2.0.0' }), + ), + writeFile(join(root, 'dist', 'cli.js'), ''), + writeFile(join(root, 'node_modules', '@maka', 'runtime-host', 'package.json'), '{}'), + ]); + return 0; + }, + ); + await assert.rejects(stat(archivePath), { code: 'ENOENT' }); + }); + + it('revalidates an archive before a coordinator can consume it', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-update-archive-')); + t.after(() => rm(root, { recursive: true, force: true })); + const archive = join(root, 'maka.tgz'); + await writeFile(archive, Buffer.from('changed archive')); + await assert.rejects( + withVerifiedRuntimeHostUpdateArchive( + { kind: 'npm_registry', version: '2.0.0', integrity: INTEGRITY }, + archive, + async () => assert.fail('mismatched archive must not be consumed'), + async () => assert.fail('mismatched archive must not reach npm'), + ), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && + error.code === 'package_integrity_mismatch', + ); + }); + it('binds the official archive to its extracted release evidence', async () => { const calls: string[][] = []; const candidate = { diff --git a/packages/cli/src/runtime-host-cli-installation.ts b/packages/cli/src/runtime-host-cli-installation.ts index 36ad47692f..4c8ae2a888 100644 --- a/packages/cli/src/runtime-host-cli-installation.ts +++ b/packages/cli/src/runtime-host-cli-installation.ts @@ -24,10 +24,8 @@ import { open, realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - isProductReleaseVersion, - type RuntimeHostInstallationOwner, -} from '@maka/runtime-host/operator'; +import type { RuntimeHostInstallationOwner } from '@maka/runtime-host/operator'; +import { isProductReleaseVersion } from '@maka/runtime-host/operator/update-package-evidence'; const PACKAGE_NAME = 'maka-agent'; const MANIFEST_MAX_BYTES = 64 * 1024; diff --git a/packages/cli/src/runtime-host-registry-update.ts b/packages/cli/src/runtime-host-registry-update.ts new file mode 100644 index 0000000000..36619396ef --- /dev/null +++ b/packages/cli/src/runtime-host-registry-update.ts @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { homedir } from 'node:os'; +import { + isProductReleaseVersion, + isSha512PackageIntegrity, + type RuntimeHostNpmDeploymentIdentity, +} from '@maka/runtime-host/operator/update-package-evidence'; +import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; + +const PACKAGE_NAME = 'maka-agent'; +const NPM_REGISTRY = 'https://registry.npmjs.org/'; +const COMPATIBILITY_FIELD = 'maka.managedRuntimeHostUpdateCompatibility'; +const REGISTRY_TIMEOUT_MS = 30_000; +const REGISTRY_OUTPUT_MAX_BYTES = 64 * 1024; + +export interface RuntimeHostUpdateCandidate extends RuntimeHostNpmDeploymentIdentity { + readonly compatibility?: number; +} + +export class RuntimeHostUpdateDiscoveryError extends Error { + constructor( + readonly code: 'target_unavailable' | 'registry_unavailable' | 'invalid_registry_metadata', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RuntimeHostUpdateDiscoveryError'; + } +} + +interface NpmViewResult { + readonly exitCode: number; + readonly stdout: string; +} + +export async function resolveRuntimeHostRegistryUpdateCandidate( + selector: RuntimeHostUpdateSelector, + run: (args: readonly string[]) => Promise = runNpmView, +): Promise { + const target = selector.kind === 'channel' ? selector.channel : selector.version; + const result = await run([ + 'view', + `${PACKAGE_NAME}@${target}`, + 'version', + 'dist.integrity', + COMPATIBILITY_FIELD, + '--json', + '--registry', + NPM_REGISTRY, + ]); + if (result.exitCode !== 0) { + const failure = parseJson(result.stdout); + const code = isRecord(failure) && isRecord(failure.error) ? failure.error.code : undefined; + throw new RuntimeHostUpdateDiscoveryError( + code === 'E404' ? 'target_unavailable' : 'registry_unavailable', + code === 'E404' + ? `No Maka package is published for ${target}` + : 'The Maka package registry is unavailable', + ); + } + let metadata: unknown; + try { + metadata = JSON.parse(result.stdout); + } catch (error) { + throw new RuntimeHostUpdateDiscoveryError( + 'invalid_registry_metadata', + 'The npm registry returned invalid Maka package metadata', + { cause: error }, + ); + } + if (!isRecord(metadata)) return invalidMetadata(); + const version = metadata.version; + const integrity = metadata['dist.integrity']; + if ( + typeof version !== 'string' || + !isProductReleaseVersion(version) || + (selector.kind === 'exact' && version !== selector.version) || + typeof integrity !== 'string' || + !isSha512PackageIntegrity(integrity) + ) { + return invalidMetadata(); + } + const compatibility = positiveInteger(metadata[COMPATIBILITY_FIELD]); + return { + kind: 'npm_registry', + version, + integrity, + ...(compatibility === undefined ? {} : { compatibility }), + }; +} + +function runNpmView(args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn('npm', args, { + cwd: homedir(), + stdio: ['ignore', 'pipe', 'ignore'], + timeout: REGISTRY_TIMEOUT_MS, + killSignal: 'SIGKILL', + }); + let stdout = ''; + let bytes = 0; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + bytes += Buffer.byteLength(chunk, 'utf8'); + if (bytes > REGISTRY_OUTPUT_MAX_BYTES) child.kill('SIGKILL'); + else stdout += chunk; + }); + child.once('error', reject); + child.once('close', (exitCode) => { + if (bytes > REGISTRY_OUTPUT_MAX_BYTES) { + reject( + new RuntimeHostUpdateDiscoveryError( + 'invalid_registry_metadata', + 'The npm registry returned oversized Maka package metadata', + ), + ); + return; + } + resolve({ exitCode: exitCode ?? 1, stdout }); + }); + }); +} + +function invalidMetadata(): never { + throw new RuntimeHostUpdateDiscoveryError( + 'invalid_registry_metadata', + 'The npm registry returned incomplete Maka package metadata', + ); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/cli/src/runtime-host-update-discovery.ts b/packages/cli/src/runtime-host-update-discovery.ts index 0ffe6c9ee5..0a1394d0a6 100644 --- a/packages/cli/src/runtime-host-update-discovery.ts +++ b/packages/cli/src/runtime-host-update-discovery.ts @@ -17,20 +17,15 @@ * under the License. */ -import { spawn } from 'node:child_process'; import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { compareProductReleaseVersions, encodeRuntimeHostServiceManagementFrame, - isProductReleaseVersion, - isSha512PackageIntegrity, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, type RuntimeHostServiceManagementFrame, - type RuntimeHostNpmDeploymentIdentity, } from '@maka/runtime-host/operator'; import { manageRuntimeHostService, @@ -44,18 +39,21 @@ import { } from './runtime-host-service-management-command.js'; import { resolveRuntimeHostManagedPackageCliPath } from './runtime-host-managed-deployment.js'; import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; +import { + resolveRuntimeHostRegistryUpdateCandidate, + RuntimeHostUpdateDiscoveryError, + type RuntimeHostUpdateCandidate, +} from './runtime-host-registry-update.js'; + +export { + resolveRuntimeHostRegistryUpdateCandidate, + RuntimeHostUpdateDiscoveryError, + type RuntimeHostUpdateCandidate, +} from './runtime-host-registry-update.js'; const PACKAGE_NAME = 'maka-agent'; -const NPM_REGISTRY = 'https://registry.npmjs.org/'; -const COMPATIBILITY_FIELD = 'maka.managedRuntimeHostUpdateCompatibility'; -const REGISTRY_TIMEOUT_MS = 30_000; -const REGISTRY_OUTPUT_MAX_BYTES = 64 * 1024; const MANIFEST_MAX_BYTES = 64 * 1024; -export interface RuntimeHostUpdateCandidate extends RuntimeHostNpmDeploymentIdentity { - readonly compatibility?: number; -} - export type RuntimeHostUpdateCheckFrame = Extract< RuntimeHostServiceManagementFrame, { kind: 'result'; action: 'check_update' } @@ -74,17 +72,6 @@ export interface RuntimeHostUpdateCheckCliOptions extends RuntimeHostUpdateCheck readonly framed: boolean; } -export class RuntimeHostUpdateDiscoveryError extends Error { - constructor( - readonly code: 'target_unavailable' | 'registry_unavailable' | 'invalid_registry_metadata', - message: string, - options?: ErrorOptions, - ) { - super(message, options); - this.name = 'RuntimeHostUpdateDiscoveryError'; - } -} - export async function runManagedRuntimeHostUpdateCheckCli( options: RuntimeHostUpdateCheckCliOptions, ): Promise { @@ -225,62 +212,6 @@ export function assessRuntimeHostUpdate( : { kind: 'manual_action', reason: 'compatibility_mismatch' }; } -export async function resolveRuntimeHostRegistryUpdateCandidate( - selector: RuntimeHostUpdateSelector, - run: (args: readonly string[]) => Promise = runNpmView, -): Promise { - const target = selector.kind === 'channel' ? selector.channel : selector.version; - const result = await run([ - 'view', - `${PACKAGE_NAME}@${target}`, - 'version', - 'dist.integrity', - COMPATIBILITY_FIELD, - '--json', - '--registry', - NPM_REGISTRY, - ]); - if (result.exitCode !== 0) { - const failure = parseJson(result.stdout); - const code = isRecord(failure) && isRecord(failure.error) ? failure.error.code : undefined; - throw new RuntimeHostUpdateDiscoveryError( - code === 'E404' ? 'target_unavailable' : 'registry_unavailable', - code === 'E404' - ? `No Maka package is published for ${target}` - : 'The Maka package registry is unavailable', - ); - } - let metadata: unknown; - try { - metadata = JSON.parse(result.stdout); - } catch (error) { - throw new RuntimeHostUpdateDiscoveryError( - 'invalid_registry_metadata', - 'The npm registry returned invalid Maka package metadata', - { cause: error }, - ); - } - if (!isRecord(metadata)) return invalidMetadata(); - const version = metadata.version; - const integrity = metadata['dist.integrity']; - if ( - typeof version !== 'string' || - !isProductReleaseVersion(version) || - (selector.kind === 'exact' && version !== selector.version) || - typeof integrity !== 'string' || - !isSha512PackageIntegrity(integrity) - ) { - return invalidMetadata(); - } - const compatibility = positiveInteger(metadata[COMPATIBILITY_FIELD]); - return { - kind: 'npm_registry', - version, - integrity, - ...(compatibility === undefined ? {} : { compatibility }), - }; -} - async function readPackageCompatibility( cliPath: string, expectedVersion: string, @@ -304,46 +235,6 @@ async function readPackageCompatibility( } } -interface NpmViewResult { - readonly exitCode: number; - readonly stdout: string; -} - -function runNpmView(args: readonly string[]): Promise { - return new Promise((resolve, reject) => { - const child = spawn('npm', args, { - cwd: homedir(), - stdio: ['ignore', 'pipe', 'ignore'], - timeout: REGISTRY_TIMEOUT_MS, - killSignal: 'SIGKILL', - }); - let stdout = ''; - let bytes = 0; - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - bytes += Buffer.byteLength(chunk, 'utf8'); - if (bytes > REGISTRY_OUTPUT_MAX_BYTES) { - child.kill('SIGKILL'); - return; - } - stdout += chunk; - }); - child.once('error', reject); - child.once('close', (exitCode) => { - if (bytes > REGISTRY_OUTPUT_MAX_BYTES) { - reject( - new RuntimeHostUpdateDiscoveryError( - 'invalid_registry_metadata', - 'The npm registry returned oversized Maka package metadata', - ), - ); - return; - } - resolve({ exitCode: exitCode ?? 1, stdout }); - }); - }); -} - function writeSuccess( frame: RuntimeHostUpdateCheckFrame, options: RuntimeHostUpdateCheckCliOptions, @@ -400,25 +291,10 @@ function writeFailure( } else process.stderr.write(`${error.message}\n`); } -function invalidMetadata(): never { - throw new RuntimeHostUpdateDiscoveryError( - 'invalid_registry_metadata', - 'The npm registry returned incomplete Maka package metadata', - ); -} - function positiveInteger(value: unknown): number | undefined { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined; } -function parseJson(value: string): unknown { - try { - return JSON.parse(value); - } catch { - return undefined; - } -} - function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/cli/src/runtime-host-update-package.ts b/packages/cli/src/runtime-host-update-package.ts index eacfc890a9..195325eb2d 100644 --- a/packages/cli/src/runtime-host-update-package.ts +++ b/packages/cli/src/runtime-host-update-package.ts @@ -23,8 +23,8 @@ import { createReadStream } from 'node:fs'; import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { isRuntimeHostNpmDeploymentIdentity } from '@maka/runtime-host/operator'; -import type { RuntimeHostUpdateCandidate } from './runtime-host-update-discovery.js'; +import { isRuntimeHostNpmDeploymentIdentity } from '@maka/runtime-host/operator/update-package-evidence'; +import type { RuntimeHostUpdateCandidate } from './runtime-host-registry-update.js'; const PACKAGE_NAME = 'maka-agent'; const NPM_REGISTRY = 'https://registry.npmjs.org/'; @@ -47,30 +47,48 @@ export class RuntimeHostUpdatePackageError extends Error { type RunNpm = (args: readonly string[], cwd: string) => Promise; +export interface RuntimeHostRegistryUpdateArtifact { + readonly archivePath: string; + readonly packageRoot: string; +} + export async function withRuntimeHostRegistryUpdatePackage( candidate: RuntimeHostUpdateCandidate, use: (packageRoot: string) => Promise, runNpm: RunNpm = runNpmCommand, ): Promise { - if ( - !isRuntimeHostNpmDeploymentIdentity(candidate) || - (candidate.compatibility !== undefined && - (!Number.isInteger(candidate.compatibility) || candidate.compatibility <= 0)) - ) { - throw new RuntimeHostUpdatePackageError( - 'invalid_package', - 'The selected Runtime Host update candidate is invalid', - ); - } + return withRuntimeHostRegistryUpdateArtifact( + candidate, + ({ packageRoot }) => use(packageRoot), + runNpm, + ); +} + +export async function withRuntimeHostRegistryUpdateArtifact( + candidate: RuntimeHostUpdateCandidate, + use: (artifact: RuntimeHostRegistryUpdateArtifact) => Promise, + runNpm: RunNpm = runNpmCommand, +): Promise { + return withRuntimeHostRegistryUpdateArchive( + candidate, + (archivePath) => withVerifiedRuntimeHostUpdateArchive(candidate, archivePath, use, runNpm), + runNpm, + ); +} + +export async function withRuntimeHostRegistryUpdateArchive( + candidate: RuntimeHostUpdateCandidate, + use: (archivePath: string) => Promise, + runNpm: RunNpm = runNpmCommand, +): Promise { + assertCandidate(candidate); const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-runtime-host-update-')); try { - let packageRoot: string; + let archive: string; try { const downloadRoot = join(temporaryRoot, 'download'); const downloadCache = join(temporaryRoot, 'download-cache'); - const installRoot = join(temporaryRoot, 'install'); - const emptyCache = join(temporaryRoot, 'empty-cache'); await mkdir(downloadRoot, { mode: 0o700 }); const packed = await runNpm( [ @@ -93,54 +111,108 @@ export async function withRuntimeHostRegistryUpdatePackage( ); } - const archive = await requireDownloadedArchive(downloadRoot); - if ((await packageIntegrity(archive)) !== candidate.integrity) { - throw new RuntimeHostUpdatePackageError( - 'package_integrity_mismatch', - `The downloaded Maka ${candidate.version} package does not match its registry integrity`, - ); - } - - const installed = await runNpm( - [ - 'install', - '--prefix', - installRoot, - '--ignore-scripts', - '--no-audit', - '--no-fund', - '--package-lock=false', - '--offline', - '--cache', - emptyCache, - '--registry', - OFFLINE_REGISTRY, - archive, - ], - temporaryRoot, - ); - if (installed !== 0) { - throw new RuntimeHostUpdatePackageError( - 'invalid_package', - `Unable to extract the verified Maka ${candidate.version} package`, - ); - } - - packageRoot = await validateExtractedPackage(installRoot, candidate); + archive = await requireDownloadedArchive(downloadRoot); } catch (error) { if (error instanceof RuntimeHostUpdatePackageError) throw error; throw new RuntimeHostUpdatePackageError( 'package_download_failed', - `Unable to prepare Maka ${candidate.version} for a managed Runtime Host update`, + `Unable to prepare Maka ${candidate.version} for an update`, { cause: error }, ); } - return await use(packageRoot); + const verifiedArchive = await validateArchive(archive, candidate); + return await use(verifiedArchive); } finally { await rm(temporaryRoot, { recursive: true, force: true }).catch(() => undefined); } } +export async function withVerifiedRuntimeHostUpdateArchive( + candidate: RuntimeHostUpdateCandidate, + archivePath: string, + use: (artifact: RuntimeHostRegistryUpdateArtifact) => Promise, + runNpm: RunNpm = runNpmCommand, + parentTemporaryRoot?: string, +): Promise { + assertCandidate(candidate); + const temporaryRoot = + parentTemporaryRoot ?? (await mkdtemp(join(tmpdir(), 'maka-runtime-host-update-'))); + try { + const archive = await validateArchive(archivePath, candidate); + const installRoot = join(temporaryRoot, 'install'); + const emptyCache = join(temporaryRoot, 'empty-cache'); + const installed = await runNpm( + [ + 'install', + '--prefix', + installRoot, + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + '--offline', + '--cache', + emptyCache, + '--registry', + OFFLINE_REGISTRY, + archive, + ], + temporaryRoot, + ); + if (installed !== 0) { + throw new RuntimeHostUpdatePackageError( + 'invalid_package', + `Unable to extract the verified Maka ${candidate.version} package`, + ); + } + const packageRoot = await validateExtractedPackage(installRoot, candidate); + return await use({ archivePath: archive, packageRoot }); + } finally { + if (parentTemporaryRoot === undefined) { + await rm(temporaryRoot, { recursive: true, force: true }).catch(() => undefined); + } + } +} + +async function validateArchive( + archivePath: string, + candidate: RuntimeHostUpdateCandidate, +): Promise { + const archive = await realpath(archivePath); + const [metadata, target] = await Promise.all([stat(archive), lstat(archive)]); + if ( + !metadata.isFile() || + target.isSymbolicLink() || + metadata.size <= 0 || + metadata.size > ARCHIVE_MAX_BYTES + ) { + throw new RuntimeHostUpdatePackageError( + 'invalid_package', + 'The Maka package archive is invalid', + ); + } + if ((await packageIntegrity(archive)) !== candidate.integrity) { + throw new RuntimeHostUpdatePackageError( + 'package_integrity_mismatch', + `The downloaded Maka ${candidate.version} package does not match its registry integrity`, + ); + } + return archive; +} + +function assertCandidate(candidate: RuntimeHostUpdateCandidate): void { + if ( + !isRuntimeHostNpmDeploymentIdentity(candidate) || + (candidate.compatibility !== undefined && + (!Number.isInteger(candidate.compatibility) || candidate.compatibility <= 0)) + ) { + throw new RuntimeHostUpdatePackageError( + 'invalid_package', + 'The selected Runtime Host update candidate is invalid', + ); + } +} + async function requireDownloadedArchive(downloadRoot: string): Promise { const entries = await readdir(downloadRoot, { withFileTypes: true }); if (entries.length !== 1 || !entries[0]?.isFile() || !entries[0].name.endsWith('.tgz')) { diff --git a/packages/runtime-host/package.json b/packages/runtime-host/package.json index d7591a76c4..294cc88478 100644 --- a/packages/runtime-host/package.json +++ b/packages/runtime-host/package.json @@ -10,6 +10,7 @@ "./protocol": "./dist/protocol/index.js", "./client": "./dist/client/index.js", "./operator": "./dist/operator/index.js", + "./operator/update-package-evidence": "./dist/operator/update-package-evidence.js", "./execution-candidate-main": "./dist/execution-candidate-main.js", "./server": "./dist/server/index.js", "./test-only/client-capability-host": "./dist/test-only/client-capability-host.js", From 90db36f05b0cfeeeee86a62d7df45d507881514f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 26 Aug 2026 21:20:54 +0800 Subject: [PATCH 2/6] feat(runtime-host): serialize deployment source finalization --- .../local-process-deployment-handoff.test.ts | 64 +++++++++++++++++++ .../src/operator/local-deployment-owner.ts | 11 ++-- .../local-process-deployment-handoff.ts | 37 ++++++++++- .../src/__tests__/file-update-lock.test.ts | 64 +++++++++++++++++++ .../fixtures/file-update-lock-holder.ts | 13 ++++ .../src/process-lifetime-file-update-lock.ts | 8 ++- 6 files changed, 188 insertions(+), 9 deletions(-) diff --git a/packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts b/packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts index b6d5ace6ce..96b0518a1c 100644 --- a/packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts +++ b/packages/runtime-host/src/__tests__/local-process-deployment-handoff.test.ts @@ -158,6 +158,70 @@ test('stages first and commits only after retirement, writer release, and exact assert.deepEqual(retryEvents, ['stage:2.0.0:desktop-to-cli']); }); +test('serializes source finalization after exact Ready and before authority commit', async (t) => { + const options = await authority(t); + const initial = await claimed(options); + const events: string[] = []; + const result = await handoffLocalHostProcessDeployment( + { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'source-finalization', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + { + ...adapter(events, 'target_present'), + async finalizeTarget(rootId, target) { + const intent = await readLocalHostDeploymentRecord(rootId, options); + assert.equal(intent?.state.kind, 'handoff'); + assert.deepEqual(target, TARGET_DEPLOYMENT); + events.push('finalize'); + }, + }, + options, + ); + + assert.equal(result.kind, 'completed'); + assert.deepEqual(events, [ + 'stage:2.0.0:source-finalization', + 'retire:refuse_active_work', + 'ready:2.0.0', + 'finalize', + ]); + assert.equal(result.record.state.kind, 'owned'); +}); + +test('keeps handoff intent recoverable when source finalization fails', async (t) => { + const options = await authority(t); + const initial = await claimed(options); + const result = await handoffLocalHostProcessDeployment( + { + rootId: ROOT_ID, + expectedRevision: initial.revision, + transactionId: 'failed-source-finalization', + from: DESKTOP, + to: CLI, + target: TARGET_DEPLOYMENT, + activeWorkPolicy: 'refuse_active_work', + }, + { + ...adapter([], 'target_present'), + finalizeTarget: async () => { + throw new Error('package switch failed'); + }, + }, + options, + ); + + assert.equal(result.kind, 'recovery_required'); + if (result.kind !== 'recovery_required') return; + assert.equal(result.phase, 'finalize_target'); + assert.equal((await readLocalHostDeploymentRecord(ROOT_ID, options))?.state.kind, 'handoff'); +}); + test('replaces a deployment without inventing a second same-owner transaction', async (t) => { const options = await authority(t); const initial = await claimed(options); diff --git a/packages/runtime-host/src/operator/local-deployment-owner.ts b/packages/runtime-host/src/operator/local-deployment-owner.ts index 73b97df412..6f8bc68a8f 100644 --- a/packages/runtime-host/src/operator/local-deployment-owner.ts +++ b/packages/runtime-host/src/operator/local-deployment-owner.ts @@ -323,12 +323,13 @@ export async function applyLocalHostDeploymentTransition( /** * Holds the one deployment-authority lock while a caller coordinates a complete - * owner transition. The callback receives the only mutation capability so it - * cannot accidentally acquire a second lock for the same record. + * owner transition. The callback receives the only mutation capability and an + * inheritable lease descriptor, so an exact child finalizer can keep the same + * authority serialized if its parent exits. */ export async function withLocalHostDeploymentAuthority( rootId: string, - operation: (authority: LocalHostDeploymentAuthority) => Promise, + operation: (authority: LocalHostDeploymentAuthority, inheritableLeaseFd: number) => Promise, options: LocalHostDeploymentAuthorityOptions = {}, ): Promise { assertRootId(rootId); @@ -339,7 +340,7 @@ export async function withLocalHostDeploymentAuthority( try { return await withProcessLifetimeFileUpdateLock( path, - async () => { + async (inheritableLeaseFd) => { await removeAbandonedRecordWorkspaces(authorityRoot, rootId, options); const authority: LocalHostDeploymentAuthority = { read: () => readRecord(path, rootId), @@ -357,7 +358,7 @@ export async function withLocalHostDeploymentAuthority( return result; }, }; - return operation(authority); + return operation(authority, inheritableLeaseFd); }, AUTHORITY_LOCK_TIMEOUT_MS, ); diff --git a/packages/runtime-host/src/operator/local-process-deployment-handoff.ts b/packages/runtime-host/src/operator/local-process-deployment-handoff.ts index 3fb7f4de1f..33d6a48051 100644 --- a/packages/runtime-host/src/operator/local-process-deployment-handoff.ts +++ b/packages/runtime-host/src/operator/local-process-deployment-handoff.ts @@ -56,6 +56,7 @@ export interface LocalHostProcessDeploymentHandoffAdapter { target: RuntimeHostDeploymentIdentity, staged: StagedTarget, policy: LocalHostHandoffActiveWorkPolicy, + inheritableAuthorityLeaseFd: number, ): Promise<{ readonly kind: 'target_absent' | 'target_present' | 'active_work'; }>; @@ -69,6 +70,13 @@ export interface LocalHostProcessDeploymentHandoffAdapter { target: RuntimeHostDeploymentIdentity, staged: StagedTarget, ): Promise; + /** Completes source-owned selection while the durable handoff remains serialized. */ + finalizeTarget?( + rootId: string, + target: RuntimeHostDeploymentIdentity, + staged: StagedTarget, + inheritableAuthorityLeaseFd: number, + ): Promise; } export interface LocalHostProcessDeploymentClaimAdapter { @@ -83,6 +91,7 @@ export interface LocalHostProcessDeploymentClaimAdapter { target: RuntimeHostDeploymentIdentity, staged: StagedTarget, policy: LocalHostHandoffActiveWorkPolicy, + inheritableAuthorityLeaseFd: number, ): Promise<{ readonly kind: 'target_absent' | 'target_present' | 'active_work'; }>; @@ -93,6 +102,12 @@ export interface LocalHostProcessDeploymentClaimAdapter { target: RuntimeHostDeploymentIdentity, staged: StagedTarget, ): Promise; + finalizeTarget?( + rootId: string, + target: RuntimeHostDeploymentIdentity, + staged: StagedTarget, + inheritableAuthorityLeaseFd: number, + ): Promise; } export interface LocalHostProcessDeploymentClaimRequest { @@ -108,6 +123,7 @@ export type LocalHostProcessDeploymentClaimPhase = | 'observe_writer_release' | 'activate_target' | 'verify_target_ready' + | 'finalize_target' | 'claim'; export type LocalHostProcessDeploymentClaimResult = @@ -129,6 +145,7 @@ export type LocalHostProcessDeploymentHandoffPhase = | 'observe_writer_release' | 'activate_target' | 'verify_target_ready' + | 'finalize_target' | 'commit_handoff' | 'rollback_active_work'; @@ -167,7 +184,7 @@ export async function handoffLocalHostProcessDeployment( const staged = await adapter.stageTarget(request.target, request.transactionId); return withLocalHostDeploymentAuthority( request.rootId, - async (authority) => { + async (authority, inheritableAuthorityLeaseFd) => { const current = await authority.read(); if ( current?.state.kind === 'owned' && @@ -223,6 +240,7 @@ export async function handoffLocalHostProcessDeployment( request.target, staged, request.activeWorkPolicy, + inheritableAuthorityLeaseFd, ); } catch (cause) { return recoveryRequired('prepare_host_cutover', handoffRecord, cause); @@ -263,6 +281,13 @@ export async function handoffLocalHostProcessDeployment( adapter.verifyTargetReady(request.rootId, request.target, staged), ); if (verification) return verification; + const finalizeTarget = adapter.finalizeTarget; + if (finalizeTarget) { + const finalization = await runPhase('finalize_target', handoffRecord, () => + finalizeTarget(request.rootId, request.target, staged, inheritableAuthorityLeaseFd), + ); + if (finalization) return finalization; + } let committed: Awaited>; const commitTransition = { @@ -308,7 +333,7 @@ export async function claimLocalHostProcessDeployment( const staged = await adapter.stageTarget(request.target, request.transactionId); return withLocalHostDeploymentAuthority( request.rootId, - async (authority) => { + async (authority, inheritableAuthorityLeaseFd) => { const current = await authority.read(); if (current) { return { @@ -325,6 +350,7 @@ export async function claimLocalHostProcessDeployment( request.target, staged, request.activeWorkPolicy, + inheritableAuthorityLeaseFd, ); } catch (cause) { return claimRecoveryRequired('prepare_host_cutover', cause); @@ -344,6 +370,13 @@ export async function claimLocalHostProcessDeployment( adapter.verifyTargetReady(request.rootId, request.target, staged), ); if (verification) return verification; + const finalizeTarget = adapter.finalizeTarget; + if (finalizeTarget) { + const finalization = await runClaimPhase('finalize_target', () => + finalizeTarget(request.rootId, request.target, staged, inheritableAuthorityLeaseFd), + ); + if (finalization) return finalization; + } try { const claimed = await authority.apply({ diff --git a/packages/storage/src/__tests__/file-update-lock.test.ts b/packages/storage/src/__tests__/file-update-lock.test.ts index ade8983bb4..18d759bd88 100644 --- a/packages/storage/src/__tests__/file-update-lock.test.ts +++ b/packages/storage/src/__tests__/file-update-lock.test.ts @@ -33,6 +33,48 @@ test('recovers a supervised legacy directory lock when its process is killed', a await assertKilledHolderCanBeRecovered(t, ['legacy']); }); +test('keeps the authority lease held by an inherited package-switch descriptor', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-inherited-file-update-lock-')); + const targetPath = join(root, 'state'); + const holder = fork( + new URL('./fixtures/file-update-lock-holder.js', import.meta.url), + [targetPath, 'inherit'], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ); + let inheritorPid: number | undefined; + t.after(async () => { + if (holder.exitCode === null && holder.signalCode === null) holder.kill('SIGKILL'); + if (inheritorPid !== undefined) killIfRunning(inheritorPid); + await rm(root, { recursive: true, force: true }); + }); + inheritorPid = await new Promise((resolve, reject) => { + holder.once('message', (message) => { + if ( + typeof message === 'object' && + message !== null && + 'kind' in message && + message.kind === 'locked' && + 'inheritorPid' in message && + typeof message.inheritorPid === 'number' + ) { + resolve(message.inheritorPid); + } else reject(new Error(`Unexpected child message: ${String(message)}`)); + }); + holder.once('error', reject); + }); + + holder.kill('SIGKILL'); + await new Promise((resolve) => holder.once('exit', () => resolve())); + await assert.rejects( + withProcessLifetimeFileUpdateLock(targetPath, async () => undefined, 150), + /locked by another process/u, + ); + + killIfRunning(inheritorPid); + await waitForExit(inheritorPid); + await withProcessLifetimeFileUpdateLock(targetPath, async () => undefined, 2_000); +}); + async function assertKilledHolderCanBeRecovered( t: TestContext, args: readonly string[], @@ -72,3 +114,25 @@ async function assertKilledHolderCanBeRecovered( ); assert.equal(entered, true); } + +function killIfRunning(pid: number): void { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) throw error; + } +} + +async function waitForExit(pid: number): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ESRCH') return; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`Inherited lock holder ${pid} did not exit`); +} diff --git a/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts b/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts index 218eaf0fbc..9ed64f356d 100644 --- a/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts +++ b/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts @@ -17,6 +17,7 @@ * under the License. */ +import { spawn } from 'node:child_process'; import { mkdir } from 'node:fs/promises'; import { withLegacyFileUpdateLockLease, @@ -37,6 +38,18 @@ if (process.argv[3] === 'legacy') { await mkdir(`${targetPath}.lock`); await hold(); }); +} else if (process.argv[3] === 'inherit') { + await withProcessLifetimeFileUpdateLock(targetPath, async (inheritedFd) => { + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30_000)'], { + stdio: ['ignore', 'ignore', 'inherit', inheritedFd], + }); + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); + process.send?.({ kind: 'locked', inheritorPid: child.pid }); + await new Promise(() => setInterval(() => undefined, 1_000)); + }); } else { await withProcessLifetimeFileUpdateLock(targetPath, hold); } diff --git a/packages/storage/src/process-lifetime-file-update-lock.ts b/packages/storage/src/process-lifetime-file-update-lock.ts index e8ce28ddcb..8849833378 100644 --- a/packages/storage/src/process-lifetime-file-update-lock.ts +++ b/packages/storage/src/process-lifetime-file-update-lock.ts @@ -67,9 +67,13 @@ export async function withLegacyFileUpdateLockLease( }); } +/** + * The callback may pass the lease fd as an extra child stdio descriptor. The + * advisory lock then survives a parent crash until that exact child exits. + */ export async function withProcessLifetimeFileUpdateLock( targetPath: string, - operation: () => Promise, + operation: (inheritableLeaseFd: number) => Promise, timeoutMs: number = LOCK_TIMEOUT_MS, ): Promise { const lockPath = `${targetPath}.lock`; @@ -86,7 +90,7 @@ export async function withProcessLifetimeFileUpdateLock( await recoverSupervisedLegacyLock(lockPath, `${targetPath}.supervised`); await acquireLegacyMarker(lockPath, deadline); markerCreated = true; - return await operation(); + return await operation(lease.fd); } finally { try { if (markerCreated) await unlink(lockPath).catch(ignoreMissing); From be7d64c1dfb7335ea347d95d1ca4125fbacd3497 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 26 Aug 2026 21:20:54 +0800 Subject: [PATCH 3/6] feat(cli): coordinate npm-global Runtime Host updates --- packages/cli/README.md | 11 +- packages/cli/README.zh-CN.md | 10 +- packages/cli/src/__tests__/cli.test.ts | 22 ++ ...me-host-installed-update-activator.test.ts | 120 ++++++ ...me-host-installed-update-bootstrap.test.ts | 109 ++++++ ...-host-installed-update-coordinator.test.ts | 179 +++++++++ packages/cli/src/cli-core.ts | 51 ++- packages/cli/src/runtime-host-cli.ts | 188 +++++++++- ...runtime-host-installed-update-activator.ts | 66 ++++ ...runtime-host-installed-update-bootstrap.ts | 139 +++++++ ...ntime-host-installed-update-coordinator.ts | 351 ++++++++++++++++++ .../cli/src/runtime-host-local-handoff.ts | 115 ++++-- 12 files changed, 1321 insertions(+), 40 deletions(-) create mode 100644 packages/cli/src/__tests__/runtime-host-installed-update-activator.test.ts create mode 100644 packages/cli/src/__tests__/runtime-host-installed-update-bootstrap.test.ts create mode 100644 packages/cli/src/__tests__/runtime-host-installed-update-coordinator.test.ts create mode 100644 packages/cli/src/runtime-host-installed-update-activator.ts create mode 100644 packages/cli/src/runtime-host-installed-update-bootstrap.ts create mode 100644 packages/cli/src/runtime-host-installed-update-coordinator.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index af23e15047..0595257399 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -106,13 +106,16 @@ modify. While using prereleases, keep the `next` tag explicit: ```sh -npm install --global maka-agent@next +maka update --target next maka --version ``` -Do not use a bare `npm update --global maka-agent` for beta upgrades: global npm updates follow the -`latest` tag and may select a different release line. After a stable release is available, install it -with `npm install --global maka-agent@latest`. +The update stages and verifies the exact release before replacing the local Runtime Host or the +npm-global package. It refuses to interrupt active or durable work by default. Use +`--allow-interrupt-active-tasks` only after deciding that interruption is safe. A direct +`npm install --global maka-agent@next` remains available for installation repair; do not use a bare +`npm update --global maka-agent`, because it follows `latest` and may select a different release +line. After a stable release is available, select it with `maka update --target latest`. ## Remote Runtime Host setup diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md index 6c9c20061d..2558c0373b 100644 --- a/packages/cli/README.zh-CN.md +++ b/packages/cli/README.zh-CN.md @@ -99,13 +99,15 @@ Maka 默认会在执行高权限工具操作前询问。`maka run --yolo` 会授 使用预发布版本时,请继续明确指定 `next`: ```sh -npm install --global maka-agent@next +maka update --target next maka --version ``` -Beta 升级不要使用不带 tag 的 `npm update --global maka-agent`:npm 的全局更新会跟随 -`latest`,可能选中不同的发布线。稳定版发布后,使用 -`npm install --global maka-agent@latest` 安装。 +更新流程会先 stage 并验证精确 release,再替换本地 Runtime Host 与 npm-global package; +默认不会中断 active 或 durable work。只有在你确认可以安全中断后,才使用 +`--allow-interrupt-active-tasks`。`npm install --global maka-agent@next` 仍可用于修复安装; +不要使用不带 tag 的 `npm update --global maka-agent`,因为它会跟随 `latest`,可能选中 +不同的发布线。稳定版发布后,使用 `maka update --target latest`。 ## 设置远程 Runtime Host diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 4748d53558..3fd6ba00b2 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -51,6 +51,7 @@ describe('Maka CLI args', () => { assert.match(help.text, /^ maka run /m); assert.match(help.text, /^ maka activate /m); assert.match(help.text, /^ maka eval /m); + assert.match(help.text, /^ maka update --target /m); assert.match( help.text, /^ maka --acp Serve ACP v1 over stdio \(initialize only; session support in progress\)$/m, @@ -59,6 +60,27 @@ describe('Maka CLI args', () => { assert.doesNotMatch(help.text, /cli:dev/); }); + test('requires an explicit installed update target and interruption choice', () => { + assert.deepEqual(parseMakaCliArgs(['update', '--target', 'next'], '0.1.0'), { + kind: 'runtime-host-installed-update', + selector: { kind: 'channel', channel: 'next' }, + allowInterruptActiveTasks: false, + }); + assert.deepEqual( + parseMakaCliArgs(['update', '--target', '1.2.3', '--allow-interrupt-active-tasks'], '0.1.0'), + { + kind: 'runtime-host-installed-update', + selector: { kind: 'exact', version: '1.2.3' }, + allowInterruptActiveTasks: true, + }, + ); + assert.deepEqual(parseMakaCliArgs(['update'], '0.1.0'), { + kind: 'error', + message: 'update requires --target ', + exitCode: 2, + }); + }); + test('selects a Runtime Host and Project for TUI startup', () => { assert.deepEqual(parseMakaCliArgs(['--host', 'office', '--project', 'project-1'], '0.1.0'), { kind: 'tui', diff --git a/packages/cli/src/__tests__/runtime-host-installed-update-activator.test.ts b/packages/cli/src/__tests__/runtime-host-installed-update-activator.test.ts new file mode 100644 index 0000000000..49d1880f79 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-installed-update-activator.test.ts @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + RUNTIME_HOST_COMPATIBILITY_EPOCH, + RUNTIME_HOST_PROTOCOL_VERSION, + RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, + type HostRegistration, +} from '@maka/runtime-host/protocol'; +import { runRuntimeHostInstalledUpdateActivator } from '../runtime-host-installed-update-activator.js'; + +const ROOT_ID = 'a'.repeat(64); + +test('accepts Ready evidence only from the exact target generation and process', async () => { + let closed = false; + const exitCode = await runRuntimeHostInstalledUpdateActivator( + { + rootPath: '/state', + expectedRootId: ROOT_ID, + generation: 'target-generation', + candidateEntrypoint: '/staged/candidate.js', + takeoverHostEpoch: 'old-host', + }, + { + connectOrSpawn: async (input) => ({ + kind: 'connected', + registration: registration({ + hostEpoch: 'target-host', + pid: 84, + generation: input.generation, + }), + spawnedProcess: { pid: 84, exited: new Promise(() => undefined) }, + connection: { + close: async () => { + closed = true; + }, + } as never, + }), + }, + ); + assert.equal(exitCode, 0); + assert.equal(closed, true); +}); + +test('reports active work and operator-owned lifecycle without forcing takeover', async () => { + const active = await runRuntimeHostInstalledUpdateActivator( + { + rootPath: '/state', + expectedRootId: ROOT_ID, + generation: 'target-generation', + candidateEntrypoint: '/staged/candidate.js', + takeoverHostEpoch: 'old-host', + }, + { + connectOrSpawn: async () => ({ + kind: 'upgrade_required', + registration: registration(), + restartable: false, + }), + }, + ); + assert.equal(active, 3); + + const service = await runRuntimeHostInstalledUpdateActivator( + { + rootPath: '/state', + expectedRootId: ROOT_ID, + generation: 'target-generation', + candidateEntrypoint: '/staged/candidate.js', + takeoverHostEpoch: 'old-host', + }, + { + connectOrSpawn: async () => ({ + kind: 'upgrade_required', + registration: registration({ lifecycleMode: 'service' }), + restartable: false, + }), + }, + ); + assert.equal(service, 4); +}); + +function registration(overrides: Partial = {}): HostRegistration { + return { + kind: 'maka-runtime-host', + schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, + rootId: ROOT_ID, + hostEpoch: 'old-host', + endpoint: '/tmp/maka.sock', + protocolMin: RUNTIME_HOST_PROTOCOL_VERSION, + protocolMax: RUNTIME_HOST_PROTOCOL_VERSION, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + compositionRevision: 'revision', + lifecycleMode: 'ephemeral', + state: 'ready', + pid: 42, + createdAt: new Date(0).toISOString(), + ...overrides, + }; +} diff --git a/packages/cli/src/__tests__/runtime-host-installed-update-bootstrap.test.ts b/packages/cli/src/__tests__/runtime-host-installed-update-bootstrap.test.ts new file mode 100644 index 0000000000..4427747a2a --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-installed-update-bootstrap.test.ts @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { runRuntimeHostInstalledUpdateBootstrap } from '../runtime-host-installed-update-bootstrap.js'; + +const INTEGRITY = `sha512-${Buffer.alloc(64, 9).toString('base64')}`; + +test('launches update coordination from a copy outside the mutable npm-global package', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-update-bootstrap-')); + t.after(() => rm(root, { recursive: true, force: true })); + const packageRoot = join(root, 'global', 'node_modules', 'maka-agent'); + const cliPath = join(packageRoot, 'dist', 'cli.js'); + const archivePath = join(root, 'target.tgz'); + await mkdir(join(packageRoot, 'dist'), { recursive: true }); + await Promise.all([ + writeFile(cliPath, '#!/usr/bin/env node\n'), + writeFile(archivePath, 'archive'), + ]); + let launched = false; + + const exitCode = await runRuntimeHostInstalledUpdateBootstrap( + { + rootPath: join(root, 'state'), + selector: { kind: 'channel', channel: 'next' }, + allowInterruptActiveTasks: true, + }, + { + resolveInstallation: async () => ({ + owner: { kind: 'cli', installationId: 'npm-global:slot' }, + observedRelease: { version: '1.0.0', packageRoot, cliPath }, + }), + resolveCandidate: async () => ({ + kind: 'npm_registry', + version: '2.0.0', + integrity: INTEGRITY, + compatibility: 2, + }), + withArchive: async (_target, use) => use(archivePath), + async runCoordinator(input) { + launched = true; + assert.notEqual(input.coordinatorCliPath, cliPath); + assert.equal((await stat(input.coordinatorCliPath)).isFile(), true); + assert.equal(input.archivePath, archivePath); + assert.equal(input.currentVersion, '1.0.0'); + assert.equal(input.targetVersion, '2.0.0'); + assert.equal(input.allowInterruptActiveTasks, true); + return 7; + }, + }, + ); + + assert.equal(exitCode, 7); + assert.equal(launched, true); +}); + +test('rejects unsupported downgrades before package acquisition', async () => { + let acquired = false; + await assert.rejects( + runRuntimeHostInstalledUpdateBootstrap( + { + rootPath: '/state', + selector: { kind: 'exact', version: '1.0.0' }, + allowInterruptActiveTasks: false, + }, + { + resolveInstallation: async () => ({ + owner: { kind: 'cli', installationId: 'npm-global:slot' }, + observedRelease: { + version: '2.0.0', + packageRoot: '/global/maka-agent', + cliPath: '/global/maka-agent/dist/cli.js', + }, + }), + resolveCandidate: async () => ({ + kind: 'npm_registry', + version: '1.0.0', + integrity: INTEGRITY, + }), + withArchive: async () => { + acquired = true; + throw new Error('must not acquire'); + }, + }, + ), + /Downgrading Maka/u, + ); + assert.equal(acquired, false); +}); diff --git a/packages/cli/src/__tests__/runtime-host-installed-update-coordinator.test.ts b/packages/cli/src/__tests__/runtime-host-installed-update-coordinator.test.ts new file mode 100644 index 0000000000..a828f8adb6 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-installed-update-coordinator.test.ts @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + RUNTIME_HOST_COMPATIBILITY_EPOCH, + RUNTIME_HOST_PROTOCOL_VERSION, + RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, + type HostRegistration, +} from '@maka/runtime-host/protocol'; +import { runRuntimeHostInstalledUpdateCoordinator } from '../runtime-host-installed-update-coordinator.js'; +import type { RuntimeHostLocalProcessLifecycleAdapter } from '../runtime-host-local-handoff.js'; + +const ROOT_ID = 'b'.repeat(64); +const INTEGRITY = `sha512-${Buffer.alloc(64, 4).toString('base64')}`; +const OWNER = { kind: 'cli' as const, installationId: 'npm-global:slot' }; + +test('retires with the current package, activates with the target, then switches npm before commit', async () => { + const events: string[] = []; + let installationRead = 0; + let hostObservation = 0; + const oldInstallation = { + owner: OWNER, + observedRelease: { + version: '1.0.0', + packageRoot: '/global/node_modules/maka-agent', + cliPath: '/global/node_modules/maka-agent/dist/cli.js', + }, + }; + const target = { kind: 'npm_registry' as const, version: '2.0.0', integrity: INTEGRITY }; + + const exitCode = await runRuntimeHostInstalledUpdateCoordinator( + { + rootPath: '/state', + archivePath: '/temporary/target.tgz', + installedPackageRoot: oldInstallation.observedRelease.packageRoot, + installedCliPath: oldInstallation.observedRelease.cliPath, + currentVersion: oldInstallation.observedRelease.version, + target, + allowInterruptActiveTasks: true, + }, + {}, + { + resolveInstallation: async () => { + installationRead += 1; + events.push( + installationRead === 1 ? 'observe-old-installation' : 'verify-new-installation', + ); + return installationRead === 1 + ? oldInstallation + : { + owner: OWNER, + observedRelease: { ...oldInstallation.observedRelease, version: target.version }, + }; + }, + resolveRoot: async () => + ({ kind: 'interactive', canonicalPath: '/state', rootId: ROOT_ID }) as never, + withArchive: async (_target, archivePath, use) => { + assert.equal(archivePath, '/temporary/target.tgz'); + return use({ archivePath, packageRoot: '/temporary/target-package' }); + }, + prepareStaged: async (input) => { + events.push('stage-target'); + assert.equal(input.sourcePackageRoot, '/temporary/target-package'); + return { + version: target.version, + root: '/store', + packageRoot: '/store/target', + cliPath: '/store/target/dist/cli.js', + candidateEntrypoint: '/store/target/runtime-host.js', + launchGeneration: 'target-generation', + cleanup: async () => {}, + rollback: async () => {}, + }; + }, + connectExisting: async () => { + hostObservation += 1; + return { + kind: 'connected', + registration: registration(), + connection: { + close: async () => + events.push(hostObservation === 1 ? 'close-preliminary' : 'close-old-host'), + } as never, + }; + }, + prepareRetirement: async (_connection, mode) => { + events.push(`retire:${mode}`); + return { kind: 'prepared', pid: 42 }; + }, + activateTarget: async (input) => { + events.push('activate-target'); + assert.equal(input.takeoverHostEpoch, 'old-host'); + assert.equal(input.inheritableAuthorityLeaseFd, 17); + return 'ready'; + }, + installArchive: async (archivePath, inheritableAuthorityLeaseFd) => { + assert.equal(archivePath, '/temporary/target.tgz'); + assert.equal(inheritableAuthorityLeaseFd, 17); + events.push('switch-global-package'); + }, + reconcile: (async (_request: unknown, lifecycle: RuntimeHostLocalProcessLifecycleAdapter) => { + assert.deepEqual( + await lifecycle.prepareHostCutover( + ROOT_ID, + target, + target, + undefined as never, + 'interrupt_active_work', + 17, + ), + { kind: 'target_present' }, + ); + await lifecycle.verifyTargetReady(ROOT_ID, target, undefined as never); + await lifecycle.finalizeTarget?.(ROOT_ID, target, undefined as never, 17); + events.push('commit-owner'); + return { + kind: 'completed', + record: { + schemaVersion: 1, + rootId: ROOT_ID, + revision: '00000000-0000-4000-8000-000000000000', + state: { kind: 'owned', owner: OWNER, selected: target }, + }, + }; + }) as never, + }, + ); + + assert.equal(exitCode, 0); + assert.deepEqual(events, [ + 'observe-old-installation', + 'stage-target', + 'close-preliminary', + 'retire:interrupt_active_work', + 'close-old-host', + 'activate-target', + 'switch-global-package', + 'verify-new-installation', + 'commit-owner', + ]); +}); + +function registration(): HostRegistration { + return { + kind: 'maka-runtime-host', + schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, + rootId: ROOT_ID, + hostEpoch: 'old-host', + endpoint: '/tmp/maka.sock', + protocolMin: RUNTIME_HOST_PROTOCOL_VERSION, + protocolMax: RUNTIME_HOST_PROTOCOL_VERSION, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + compositionRevision: 'revision', + lifecycleMode: 'ephemeral', + state: 'ready', + pid: 42, + createdAt: new Date(0).toISOString(), + }; +} diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 61ab52fcb6..ae121e75d6 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -26,7 +26,11 @@ import { resolveRuntimeHostManagedPeerKeyPath, resolveRuntimeHostPeerNativePath, } from './runtime-host-peer-artifact.js'; -import { parseRuntimeHostCommand, type RuntimeHostCliCommand } from './runtime-host-cli.js'; +import { + parseRuntimeHostCommand, + parseRuntimeHostInstalledUpdateCommand, + type RuntimeHostCliCommand, +} from './runtime-host-cli.js'; import { resolveCliUiLocale } from './cli-ui-locale.js'; export type MakaCliCommand = @@ -81,6 +85,7 @@ export function parseMakaCliArgs( if (first === 'run' || first === '-p') return { kind: 'run', args: argv.slice(1) }; if (first === 'activate') return { kind: 'activate', args: argv.slice(1) }; if (first === 'eval') return { kind: 'eval', args: argv.slice(1) }; + if (first === 'update') return parseRuntimeHostInstalledUpdateCommand(argv.slice(1)); if (first === 'runtime-host') return parseRuntimeHostCommand(argv.slice(1)); return { kind: 'error', @@ -134,6 +139,7 @@ function helpText(cliCommand: string): string { ` ${cliCommand} activate ... Run one Cloud Session activation and emit JSONL`, ` ${cliCommand} -p ... Alias for ${cliCommand} run`, ` ${cliCommand} eval ... Run one declarative multi-arm experiment`, + ` ${cliCommand} update --target Update this npm-global CLI and its local Runtime Host`, ` ${cliCommand} runtime-host serve [options] Run a Runtime Host service`, ` ${cliCommand} runtime-host activate --framed --root-id `, ` ${cliCommand} runtime-host setup --principal --preset [options]`, @@ -301,6 +307,49 @@ export async function runMakaCli( ...(command.peer ? { peer: command.peer } : {}), }); } + case 'runtime-host-installed-update': { + const { runRuntimeHostInstalledUpdateBootstrap } = await import( + './runtime-host-installed-update-bootstrap.js' + ); + return runRuntimeHostInstalledUpdateBootstrap({ + rootPath: dataRoots.workspaceRoot, + selector: command.selector, + allowInterruptActiveTasks: command.allowInterruptActiveTasks, + }); + } + case 'runtime-host-local-update-apply': { + const { runRuntimeHostInstalledUpdateCoordinator } = await import( + './runtime-host-installed-update-coordinator.js' + ); + return runRuntimeHostInstalledUpdateCoordinator({ + rootPath: command.rootPath, + archivePath: command.archivePath, + installedPackageRoot: command.installedPackageRoot, + installedCliPath: command.installedCliPath, + currentVersion: command.currentVersion, + target: { + kind: 'npm_registry', + version: command.targetVersion, + integrity: command.targetIntegrity, + ...(command.targetCompatibility === undefined + ? {} + : { compatibility: command.targetCompatibility }), + }, + allowInterruptActiveTasks: command.allowInterruptActiveTasks, + }); + } + case 'runtime-host-local-update-activate': { + const { runRuntimeHostInstalledUpdateActivator } = await import( + './runtime-host-installed-update-activator.js' + ); + return runRuntimeHostInstalledUpdateActivator({ + rootPath: command.rootPath, + expectedRootId: command.expectedRootId, + generation: command.generation, + candidateEntrypoint: command.candidateEntrypoint, + ...(command.takeoverHostEpoch ? { takeoverHostEpoch: command.takeoverHostEpoch } : {}), + }); + } case 'runtime-host-setup': { const { runRuntimeHostSetupCli } = await import('./runtime-host-setup-command.js'); return runRuntimeHostSetupCli({ diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index 2c7044814f..1613386ca0 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -18,7 +18,10 @@ */ import { isAbsolute } from 'node:path'; -import { isProductReleaseVersion } from '@maka/runtime-host/operator'; +import { + isProductReleaseVersion, + isSha512PackageIntegrity, +} from '@maka/runtime-host/operator/update-package-evidence'; import type { RuntimeHostManagedUpdatePolicy } from '@maka/runtime-host/operator'; import { canonicalProjectDirectoryRootSpec, @@ -42,6 +45,31 @@ export type RuntimeHostCliCommand = rootId: string; framed: true; } + | { + kind: 'runtime-host-installed-update'; + selector: RuntimeHostUpdateSelector; + allowInterruptActiveTasks: boolean; + } + | { + kind: 'runtime-host-local-update-apply'; + rootPath: string; + archivePath: string; + installedPackageRoot: string; + installedCliPath: string; + currentVersion: string; + targetVersion: string; + targetIntegrity: string; + targetCompatibility?: number; + allowInterruptActiveTasks: boolean; + } + | { + kind: 'runtime-host-local-update-activate'; + rootPath: string; + expectedRootId: string; + generation: string; + candidateEntrypoint: string; + takeoverHostEpoch?: string; + } | { kind: 'runtime-host-serve'; rootPath?: string; @@ -234,6 +262,8 @@ export type RuntimeHostCliCommand = export function parseRuntimeHostCommand(argv: string[]): RuntimeHostCliCommand { if (argv[0] === 'activate') return parseManagedActivationCommand(argv.slice(1)); + if (argv[0] === 'local-update-apply') return parseLocalUpdateApply(argv.slice(1)); + if (argv[0] === 'local-update-activate') return parseLocalUpdateActivate(argv.slice(1)); if (argv[0] === 'serve') return parseServeCommand(argv.slice(1)); if (argv[0] === 'setup') return parseSetupCommand(argv.slice(1)); if (argv[0] === 'service') return parseServiceManagementCommand(argv.slice(1)); @@ -276,6 +306,162 @@ function parseManagedActivationCommand(argv: string[]): RuntimeHostCliCommand { return { kind: 'runtime-host-managed-activate', rootId, framed: true }; } +export function parseRuntimeHostInstalledUpdateCommand(argv: string[]): RuntimeHostCliCommand { + let target: string | undefined; + let allowInterruptActiveTasks = false; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--target') { + const parsed = optionValue(argv, index, argument); + if (typeof parsed !== 'string') return parsed; + if (target !== undefined) return error('Duplicate --target'); + target = parsed; + index += 1; + continue; + } + if (argument === '--allow-interrupt-active-tasks') { + if (allowInterruptActiveTasks) { + return error('Duplicate --allow-interrupt-active-tasks'); + } + allowInterruptActiveTasks = true; + continue; + } + return error(`Unexpected argument: ${argument ?? ''}`); + } + if (!target) return error('update requires --target '); + const selector = parseUpdateSelector(target, 'update'); + if ('kind' in selector && selector.kind === 'error') return selector; + return { kind: 'runtime-host-installed-update', selector, allowInterruptActiveTasks }; +} + +function parseLocalUpdateApply(argv: string[]): RuntimeHostCliCommand { + const values = new Map(); + let allowInterruptActiveTasks = false; + const valueOptions = new Set([ + '--root', + '--archive', + '--installed-package-root', + '--installed-cli-path', + '--current-version', + '--target-version', + '--target-integrity', + '--target-compatibility', + ]); + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--allow-interrupt-active-tasks') { + if (allowInterruptActiveTasks) return error(`Duplicate ${argument}`); + allowInterruptActiveTasks = true; + continue; + } + if (!argument || !valueOptions.has(argument)) + return error(`Unexpected argument: ${argument ?? ''}`); + if (values.has(argument)) return error(`Duplicate ${argument}`); + const parsed = optionValue(argv, index, argument); + if (typeof parsed !== 'string') return parsed; + values.set(argument, parsed); + index += 1; + } + const required = (name: string): string | RuntimeHostCliError => { + const value = values.get(name); + return value ? value : error(`runtime-host local-update-apply requires ${name}`); + }; + const rootPath = required('--root'); + if (typeof rootPath !== 'string') return rootPath; + const archivePath = required('--archive'); + if (typeof archivePath !== 'string') return archivePath; + const installedPackageRoot = required('--installed-package-root'); + if (typeof installedPackageRoot !== 'string') return installedPackageRoot; + const installedCliPath = required('--installed-cli-path'); + if (typeof installedCliPath !== 'string') return installedCliPath; + if (![rootPath, archivePath, installedPackageRoot, installedCliPath].every(isSafeAbsolutePath)) { + return error('runtime-host local-update-apply paths must be absolute'); + } + const currentVersion = required('--current-version'); + if (typeof currentVersion !== 'string') return currentVersion; + const targetVersion = required('--target-version'); + if (typeof targetVersion !== 'string') return targetVersion; + const targetIntegrity = required('--target-integrity'); + if (typeof targetIntegrity !== 'string') return targetIntegrity; + if (!isProductReleaseVersion(currentVersion) || !isProductReleaseVersion(targetVersion)) { + return error('runtime-host local-update-apply versions are invalid'); + } + if (!isSha512PackageIntegrity(targetIntegrity)) { + return error('runtime-host local-update-apply integrity is invalid'); + } + const rawCompatibility = values.get('--target-compatibility'); + const targetCompatibility = rawCompatibility === undefined ? undefined : Number(rawCompatibility); + if ( + targetCompatibility !== undefined && + (!Number.isSafeInteger(targetCompatibility) || targetCompatibility <= 0) + ) { + return error('runtime-host local-update-apply compatibility is invalid'); + } + return { + kind: 'runtime-host-local-update-apply', + rootPath, + archivePath, + installedPackageRoot, + installedCliPath, + currentVersion, + targetVersion, + targetIntegrity, + ...(targetCompatibility === undefined ? {} : { targetCompatibility }), + allowInterruptActiveTasks, + }; +} + +function parseLocalUpdateActivate(argv: string[]): RuntimeHostCliCommand { + const values = new Map(); + const options = new Set([ + '--root', + '--expected-root-id', + '--generation', + '--candidate-entrypoint', + '--takeover-host-epoch', + ]); + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!argument || !options.has(argument)) return error(`Unexpected argument: ${argument ?? ''}`); + if (values.has(argument)) return error(`Duplicate ${argument}`); + const parsed = optionValue(argv, index, argument); + if (typeof parsed !== 'string') return parsed; + values.set(argument, parsed); + index += 1; + } + const rootPath = values.get('--root'); + const expectedRootId = values.get('--expected-root-id'); + const generation = values.get('--generation'); + const candidateEntrypoint = values.get('--candidate-entrypoint'); + const takeoverHostEpoch = values.get('--takeover-host-epoch'); + if (!rootPath || !expectedRootId || !generation || !candidateEntrypoint) { + return error('runtime-host local-update-activate requires its exact target identity'); + } + if (!isSafeAbsolutePath(rootPath) || !isSafeAbsolutePath(candidateEntrypoint)) { + return error('runtime-host local-update-activate paths must be absolute'); + } + if (!/^[a-f0-9]{64}$/u.test(expectedRootId)) { + return error('runtime-host local-update-activate root identity is invalid'); + } + if ( + [generation, takeoverHostEpoch].some((value) => value !== undefined && !isSafeIdentity(value)) + ) { + return error('runtime-host local-update-activate generation is invalid'); + } + return { + kind: 'runtime-host-local-update-activate', + rootPath, + expectedRootId, + generation, + candidateEntrypoint, + ...(takeoverHostEpoch ? { takeoverHostEpoch } : {}), + }; +} + +function isSafeIdentity(value: string): boolean { + return value.length <= 512 && !/[\u0000-\u001f\u007f]/u.test(value); +} + function parseSetupCommand(argv: string[]): RuntimeHostCliCommand { let principalId: string | undefined; let preset: 'desktop-client' | 'terminal-client' | undefined; diff --git a/packages/cli/src/runtime-host-installed-update-activator.ts b/packages/cli/src/runtime-host-installed-update-activator.ts new file mode 100644 index 0000000000..ef4c4cec27 --- /dev/null +++ b/packages/cli/src/runtime-host-installed-update-activator.ts @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { connectOrSpawnRuntimeHost, runtimeHostStartupError } from '@maka/runtime-host/client'; +import { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + RUNTIME_HOST_PROTOCOL_VERSION, +} from '@maka/runtime-host/protocol'; + +export async function runRuntimeHostInstalledUpdateActivator( + input: { + readonly rootPath: string; + readonly expectedRootId: string; + readonly generation: string; + readonly candidateEntrypoint: string; + readonly takeoverHostEpoch?: string; + }, + overrides: { readonly connectOrSpawn?: typeof connectOrSpawnRuntimeHost } = {}, +): Promise { + const result = await (overrides.connectOrSpawn ?? connectOrSpawnRuntimeHost)({ + rootPath: input.rootPath, + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + generation: input.generation, + ...(input.takeoverHostEpoch ? { takeoverHostEpoch: input.takeoverHostEpoch } : {}), + clientInstanceId: randomUUID(), + candidateEntrypoint: input.candidateEntrypoint, + }); + if (result.kind === 'connected') { + try { + if ( + result.registration.rootId !== input.expectedRootId || + result.registration.generation !== input.generation || + (result.spawnedProcess !== undefined && + result.spawnedProcess.pid !== result.registration.pid) + ) { + throw new Error('The activated Runtime Host does not match the exact staged target'); + } + return 0; + } finally { + await result.connection.close().catch(() => undefined); + } + } + if (result.kind === 'failed') { + throw runtimeHostStartupError(result.reason, result.diagnostic); + } + if (result.registration.lifecycleMode !== 'ephemeral') return 4; + return 3; +} diff --git a/packages/cli/src/runtime-host-installed-update-bootstrap.ts b/packages/cli/src/runtime-host-installed-update-bootstrap.ts new file mode 100644 index 0000000000..8a0bd83b9b --- /dev/null +++ b/packages/cli/src/runtime-host-installed-update-bootstrap.ts @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { cp, mkdtemp, realpath, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { compareProductReleaseVersions } from '@maka/runtime-host/operator/update-package-evidence'; +import { resolveRuntimeHostNpmGlobalInstallation } from './runtime-host-cli-installation.js'; +import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; +import { resolveRuntimeHostRegistryUpdateCandidate } from './runtime-host-registry-update.js'; +import { withRuntimeHostRegistryUpdateArchive } from './runtime-host-update-package.js'; + +interface RuntimeHostInstalledUpdateBootstrapDeps { + readonly resolveInstallation: typeof resolveRuntimeHostNpmGlobalInstallation; + readonly resolveCandidate: typeof resolveRuntimeHostRegistryUpdateCandidate; + readonly withArchive: typeof withRuntimeHostRegistryUpdateArchive; + readonly runCoordinator: (input: RuntimeHostInstalledUpdateCoordinatorLaunch) => Promise; +} + +interface RuntimeHostInstalledUpdateCoordinatorLaunch { + readonly coordinatorCliPath: string; + readonly rootPath: string; + readonly archivePath: string; + readonly installedPackageRoot: string; + readonly installedCliPath: string; + readonly currentVersion: string; + readonly targetVersion: string; + readonly targetIntegrity: string; + readonly targetCompatibility?: number; + readonly allowInterruptActiveTasks: boolean; +} + +export async function runRuntimeHostInstalledUpdateBootstrap( + input: { + readonly rootPath: string; + readonly selector: RuntimeHostUpdateSelector; + readonly allowInterruptActiveTasks: boolean; + }, + overrides: Partial = {}, +): Promise { + const deps: RuntimeHostInstalledUpdateBootstrapDeps = { + resolveInstallation: resolveRuntimeHostNpmGlobalInstallation, + resolveCandidate: resolveRuntimeHostRegistryUpdateCandidate, + withArchive: withRuntimeHostRegistryUpdateArchive, + runCoordinator: launchCoordinator, + ...overrides, + }; + const installation = await deps.resolveInstallation(); + const target = await deps.resolveCandidate(input.selector); + if (compareProductReleaseVersions(target.version, installation.observedRelease.version) < 0) { + throw new Error( + `Downgrading Maka from ${installation.observedRelease.version} to ${target.version} is not supported`, + ); + } + + return deps.withArchive(target, async (archivePath) => { + const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-installed-update-coordinator-')); + try { + const coordinatorPackageRoot = join(temporaryRoot, 'maka-agent'); + await cp(installation.observedRelease.packageRoot, coordinatorPackageRoot, { + recursive: true, + force: false, + errorOnExist: true, + preserveTimestamps: true, + }); + const coordinatorCliPath = await realpath(join(coordinatorPackageRoot, 'dist', 'cli.js')); + if (!(await stat(coordinatorCliPath)).isFile()) { + throw new Error('The copied Maka update coordinator has no CLI entry point'); + } + return deps.runCoordinator({ + coordinatorCliPath, + rootPath: input.rootPath, + archivePath, + installedPackageRoot: installation.observedRelease.packageRoot, + installedCliPath: installation.observedRelease.cliPath, + currentVersion: installation.observedRelease.version, + targetVersion: target.version, + targetIntegrity: target.integrity, + ...(target.compatibility === undefined + ? {} + : { targetCompatibility: target.compatibility }), + allowInterruptActiveTasks: input.allowInterruptActiveTasks, + }); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }).catch(() => undefined); + } + }); +} + +function launchCoordinator(input: RuntimeHostInstalledUpdateCoordinatorLaunch): Promise { + const args = [ + input.coordinatorCliPath, + 'runtime-host', + 'local-update-apply', + '--root', + input.rootPath, + '--archive', + input.archivePath, + '--installed-package-root', + input.installedPackageRoot, + '--installed-cli-path', + input.installedCliPath, + '--current-version', + input.currentVersion, + '--target-version', + input.targetVersion, + '--target-integrity', + input.targetIntegrity, + ...(input.targetCompatibility === undefined + ? [] + : ['--target-compatibility', String(input.targetCompatibility)]), + ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), + ]; + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { stdio: 'inherit', windowsHide: false }); + child.once('error', reject); + child.once('close', (code, signal) => { + if (signal) reject(new Error(`Maka update coordinator exited on ${signal}`)); + else resolve(code ?? 1); + }); + }); +} diff --git a/packages/cli/src/runtime-host-installed-update-coordinator.ts b/packages/cli/src/runtime-host-installed-update-coordinator.ts new file mode 100644 index 0000000000..a854f3eca3 --- /dev/null +++ b/packages/cli/src/runtime-host-installed-update-coordinator.ts @@ -0,0 +1,351 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + connectExistingRuntimeHost, + prepareConnectedRuntimeHostRetirement, + type RuntimeHostConnection, +} from '@maka/runtime-host/client'; +import { type LocalHostDeploymentAuthorityOptions } from '@maka/runtime-host/operator'; +import { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + RUNTIME_HOST_PROTOCOL_VERSION, + type HostRegistration, +} from '@maka/runtime-host/protocol'; +import { resolveStorageRoot } from '@maka/storage/root-authority'; +import { + prepareRuntimeHostNpmGlobalStagedDeployment, + reconcilePreparedRuntimeHostNpmGlobalDeployment, + type RuntimeHostLocalStagedDeployment, +} from './runtime-host-local-handoff.js'; +import { + resolveRuntimeHostNpmGlobalInstallation, + type RuntimeHostNpmGlobalInstallation, +} from './runtime-host-cli-installation.js'; +import type { RuntimeHostUpdateCandidate } from './runtime-host-registry-update.js'; +import { withVerifiedRuntimeHostUpdateArchive } from './runtime-host-update-package.js'; + +const NPM_TIMEOUT_MS = 5 * 60_000; +const NPM_OUTPUT_MAX_BYTES = 64 * 1024; +const OFFLINE_REGISTRY = 'http://127.0.0.1:9/'; + +interface RuntimeHostInstalledUpdateCoordinatorDeps { + readonly resolveInstallation: typeof resolveRuntimeHostNpmGlobalInstallation; + readonly resolveRoot: typeof resolveStorageRoot; + readonly connectExisting: typeof connectExistingRuntimeHost; + readonly prepareRetirement: typeof prepareConnectedRuntimeHostRetirement; + readonly withArchive: typeof withVerifiedRuntimeHostUpdateArchive; + readonly prepareStaged: typeof prepareRuntimeHostNpmGlobalStagedDeployment; + readonly reconcile: typeof reconcilePreparedRuntimeHostNpmGlobalDeployment; + readonly activateTarget: ( + input: RuntimeHostTargetActivationInput, + ) => Promise<'ready' | 'active_work' | 'operator_required'>; + readonly installArchive: typeof installRuntimeHostNpmGlobalArchive; +} + +interface RuntimeHostTargetActivationInput { + readonly rootPath: string; + readonly rootId: string; + readonly staged: RuntimeHostLocalStagedDeployment; + readonly takeoverHostEpoch?: string; + readonly inheritableAuthorityLeaseFd: number; +} + +export interface RuntimeHostInstalledUpdateCoordinatorInput { + readonly rootPath: string; + readonly archivePath: string; + readonly installedPackageRoot: string; + readonly installedCliPath: string; + readonly currentVersion: string; + readonly target: RuntimeHostUpdateCandidate; + readonly allowInterruptActiveTasks: boolean; +} + +export async function runRuntimeHostInstalledUpdateCoordinator( + input: RuntimeHostInstalledUpdateCoordinatorInput, + authorityOptions: LocalHostDeploymentAuthorityOptions = {}, + overrides: Partial = {}, +): Promise { + const deps: RuntimeHostInstalledUpdateCoordinatorDeps = { + resolveInstallation: resolveRuntimeHostNpmGlobalInstallation, + resolveRoot: resolveStorageRoot, + connectExisting: connectExistingRuntimeHost, + prepareRetirement: prepareConnectedRuntimeHostRetirement, + withArchive: withVerifiedRuntimeHostUpdateArchive, + prepareStaged: prepareRuntimeHostNpmGlobalStagedDeployment, + reconcile: reconcilePreparedRuntimeHostNpmGlobalDeployment, + activateTarget: launchTargetActivator, + installArchive: installRuntimeHostNpmGlobalArchive, + ...overrides, + }; + const installationOptions = { + manifestUrl: pathToFileURL(join(input.installedPackageRoot, 'package.json')), + cliPath: input.installedCliPath, + }; + const installation = await deps.resolveInstallation(installationOptions); + if (installation.observedRelease.version !== input.currentVersion) { + throw new Error( + `The installed Maka release changed from ${input.currentVersion} to ${installation.observedRelease.version} before update coordination`, + ); + } + const root = await deps.resolveRoot({ path: input.rootPath, kind: 'interactive' }); + const transactionId = updateTransactionId(root.rootId, installation, input.target); + + return deps.withArchive(input.target, input.archivePath, async ({ packageRoot, archivePath }) => { + const staged = await deps.prepareStaged({ + rootId: root.rootId, + owner: installation.owner, + target: input.target, + transactionId, + sourcePackageRoot: packageRoot, + }); + const preliminary = await observeCurrentHost(input.rootPath, root.rootId, deps); + if (preliminary.registration && preliminary.registration.lifecycleMode !== 'ephemeral') { + throw new Error('Only an ephemeral local Runtime Host can be updated by this CLI'); + } + await preliminary.connection?.close(); + let observation: Awaited> = {}; + let targetReady = false; + const prepare = async (inheritableAuthorityLeaseFd: number) => { + observation = await observeCurrentHost(input.rootPath, root.rootId, deps); + if (observation.registration && observation.registration.lifecycleMode !== 'ephemeral') { + throw new Error('Only an ephemeral local Runtime Host can be updated by this CLI'); + } + const takeoverHostEpoch = observation.registration?.hostEpoch; + if (observation.connection) { + const retirement = await deps.prepareRetirement( + observation.connection, + input.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); + if (retirement.kind === 'active_tasks') return { kind: 'active_work' as const }; + await observation.connection.close(); + observation = { registration: observation.registration }; + } + const activated = await deps.activateTarget({ + rootPath: input.rootPath, + rootId: root.rootId, + staged, + inheritableAuthorityLeaseFd, + ...(takeoverHostEpoch ? { takeoverHostEpoch } : {}), + }); + if (activated === 'operator_required') { + throw new Error('The observed Runtime Host requires its operator to perform the update'); + } + if (activated === 'active_work') return { kind: 'active_work' as const }; + targetReady = true; + return { kind: 'target_present' as const }; + }; + const unreachable = async (): Promise => { + throw new Error('The exact target activator must settle local Host cutover'); + }; + const result = await deps.reconcile( + { + rootId: root.rootId, + transactionId, + target: input.target, + activeWorkPolicy: input.allowInterruptActiveTasks + ? 'interrupt_active_work' + : 'refuse_active_work', + installation, + staged, + }, + { + prepareUnownedHostCutover: (_rootId, _target, _staged, _policy, leaseFd) => + prepare(leaseFd), + prepareHostCutover: (_rootId, _selected, _target, _staged, _policy, leaseFd) => + prepare(leaseFd), + observeWriterRelease: unreachable, + activateTarget: unreachable, + async verifyTargetReady() { + if (!targetReady) throw new Error('The exact target Ready evidence is unavailable'); + }, + async finalizeTarget(_rootId, _target, _staged, inheritableAuthorityLeaseFd) { + await finalizeInstalledPackage( + input, + installation, + archivePath, + installationOptions, + deps, + inheritableAuthorityLeaseFd, + ); + }, + }, + authorityOptions, + ); + if (result.kind === 'completed') { + process.stdout.write(`Updated Maka to ${input.target.version}.\n`); + return 0; + } + if (result.kind === 'active_work') { + process.stderr.write( + input.allowInterruptActiveTasks + ? 'The local Runtime Host still owns work that this release cannot safely interrupt. Retry with a compatible CLI, or wait for it to become idle.\n' + : 'The local Runtime Host still owns active or durable work. Retry later, or explicitly allow interruption.\n', + ); + return 2; + } + if (result.kind === 'rejected') { + process.stderr.write(`The local Runtime Host update was rejected: ${result.reason}.\n`); + return 2; + } + process.stderr.write(`The local Runtime Host update requires recovery at ${result.phase}.\n`); + return 1; + }); +} + +async function observeCurrentHost( + rootPath: string, + rootId: string, + deps: Pick, +): Promise<{ connection?: RuntimeHostConnection; registration?: HostRegistration }> { + const result = await deps.connectExisting({ + rootPath, + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId: randomUUID(), + }); + if (result.registration && result.registration.rootId !== rootId) { + throw new Error('The local Runtime Host State Root changed before update'); + } + if (result.kind === 'connected') { + return { connection: result.connection, registration: result.registration }; + } + return result.registration ? { registration: result.registration } : {}; +} + +async function finalizeInstalledPackage( + input: RuntimeHostInstalledUpdateCoordinatorInput, + before: RuntimeHostNpmGlobalInstallation, + archivePath: string, + installationOptions: Parameters[0], + deps: Pick, + inheritableAuthorityLeaseFd: number, +): Promise { + if (before.observedRelease.version !== input.target.version) { + await deps.installArchive(archivePath, inheritableAuthorityLeaseFd); + } + const installed = await deps.resolveInstallation(installationOptions); + if ( + installed.owner.installationId !== before.owner.installationId || + installed.observedRelease.version !== input.target.version + ) { + throw new Error( + 'npm did not install the exact selected Maka release into the same global slot', + ); + } +} + +export function installRuntimeHostNpmGlobalArchive( + archivePath: string, + inheritableAuthorityLeaseFd: number, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + 'npm', + [ + 'install', + '--global', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--offline', + '--cache', + join(dirname(archivePath), 'install-cache'), + '--registry', + OFFLINE_REGISTRY, + archivePath, + ], + { + cwd: homedir(), + stdio: ['ignore', 'pipe', 'pipe', inheritableAuthorityLeaseFd], + timeout: NPM_TIMEOUT_MS, + killSignal: 'SIGKILL', + }, + ); + let outputBytes = 0; + const observe = (chunk: Buffer) => { + outputBytes += chunk.byteLength; + if (outputBytes > NPM_OUTPUT_MAX_BYTES) child.kill('SIGKILL'); + }; + child.stdout?.on('data', observe); + child.stderr?.on('data', observe); + child.once('error', reject); + child.once('close', (code) => { + if (outputBytes > NPM_OUTPUT_MAX_BYTES) { + reject(new Error('npm returned too much output while installing Maka')); + } else if (code !== 0) { + reject(new Error('npm could not install the selected Maka release')); + } else resolve(); + }); + }); +} + +function launchTargetActivator( + input: RuntimeHostTargetActivationInput, +): Promise<'ready' | 'active_work' | 'operator_required'> { + const args = [ + input.staged.cliPath, + 'runtime-host', + 'local-update-activate', + '--root', + input.rootPath, + '--expected-root-id', + input.rootId, + '--generation', + input.staged.launchGeneration, + '--candidate-entrypoint', + input.staged.candidateEntrypoint, + ...(input.takeoverHostEpoch ? ['--takeover-host-epoch', input.takeoverHostEpoch] : []), + ]; + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + stdio: ['inherit', 'inherit', 'inherit', input.inheritableAuthorityLeaseFd], + windowsHide: false, + }); + child.once('error', reject); + child.once('close', (code, signal) => { + if (signal) reject(new Error(`Maka target activator exited on ${signal}`)); + else if (code === 0) resolve('ready'); + else if (code === 3) resolve('active_work'); + else if (code === 4) resolve('operator_required'); + else reject(new Error('The exact Maka target could not be activated')); + }); + }); +} + +function updateTransactionId( + rootId: string, + installation: RuntimeHostNpmGlobalInstallation, + target: RuntimeHostUpdateCandidate, +): string { + return `npm-global-update:${createHash('sha256') + .update(rootId) + .update('\0') + .update(installation.owner.installationId) + .update('\0') + .update(target.version) + .update('\0') + .update(target.integrity) + .digest('hex')}`; +} diff --git a/packages/cli/src/runtime-host-local-handoff.ts b/packages/cli/src/runtime-host-local-handoff.ts index cf6543d051..f1695ea08c 100644 --- a/packages/cli/src/runtime-host-local-handoff.ts +++ b/packages/cli/src/runtime-host-local-handoff.ts @@ -38,7 +38,10 @@ import { RUNTIME_HOST_PROTOCOL_VERSION, type HostRegistration, } from '@maka/runtime-host/protocol'; -import { resolveRuntimeHostNpmGlobalInstallation } from './runtime-host-cli-installation.js'; +import { + resolveRuntimeHostNpmGlobalInstallation, + type RuntimeHostNpmGlobalInstallation, +} from './runtime-host-cli-installation.js'; import { prepareRuntimeHostPackageDeployment, type RuntimeHostPackageDeployment, @@ -286,24 +289,58 @@ export async function reconcileRuntimeHostNpmGlobalDeployment( `The installed Maka release changed from ${request.target.version} to ${installation.observedRelease.version} before local Host reconciliation`, ); } - const stageTarget = (target: RuntimeHostUpdateCandidate, transactionId: string) => - stageRuntimeHostNpmGlobalDeploymentTarget( - { - rootId: request.rootId, - owner: installation.owner, - target, - transactionId, - }, - request.deploymentPathOptions, - deps, - ); - const current = await deps.readRecord(request.rootId, authorityOptions); + const staged = await stageRuntimeHostNpmGlobalDeploymentTarget( + { + rootId: request.rootId, + owner: installation.owner, + target: request.target, + transactionId: request.transactionId, + }, + request.deploymentPathOptions, + deps, + ); + return reconcilePreparedRuntimeHostNpmGlobalDeployment( + { ...request, installation, staged }, + lifecycle, + authorityOptions, + deps, + ); +} + +export async function reconcilePreparedRuntimeHostNpmGlobalDeployment( + request: RuntimeHostNpmGlobalReconciliationRequest & { + readonly installation: RuntimeHostNpmGlobalInstallation; + readonly staged: RuntimeHostLocalStagedDeployment; + }, + lifecycle: RuntimeHostLocalProcessLifecycleAdapter, + authorityOptions: LocalHostDeploymentAuthorityOptions = {}, + overrides: Pick = { + readRecord: readLocalHostDeploymentRecord, + claim: claimLocalHostProcessDeployment, + handoff: handoffLocalHostProcessDeployment, + }, +): Promise { + const stageTarget = async (target: RuntimeHostUpdateCandidate, transactionId: string) => { + if ( + transactionId !== request.transactionId || + target.kind !== request.target.kind || + target.version !== request.target.version || + target.integrity !== request.target.integrity + ) { + throw new RuntimeHostLocalHandoffError( + 'selected_target_observation_conflict', + 'The prepared local Host target does not match its owner transaction', + ); + } + return request.staged; + }; + const current = await overrides.readRecord(request.rootId, authorityOptions); if (!current) { - return deps.claim( + return overrides.claim( { rootId: request.rootId, transactionId: request.transactionId, - owner: installation.owner, + owner: request.installation.owner, target: request.target, activeWorkPolicy: request.activeWorkPolicy, }, @@ -311,14 +348,14 @@ export async function reconcileRuntimeHostNpmGlobalDeployment( authorityOptions, ); } - return deps.handoff( + return overrides.handoff( { rootId: request.rootId, expectedRevision: current.revision, transactionId: current.state.kind === 'handoff' ? current.state.transactionId : request.transactionId, from: current.state.kind === 'handoff' ? current.state.from : current.state.owner, - to: installation.owner, + to: request.installation.owner, target: request.target, activeWorkPolicy: request.activeWorkPolicy, }, @@ -342,26 +379,44 @@ export async function stageRuntimeHostNpmGlobalDeploymentTarget( withPackage: withRuntimeHostRegistryUpdatePackage, prepareDeployment: prepareRuntimeHostPackageDeployment, }, +): Promise { + return overrides.withPackage(input.target, async (sourcePackageRoot) => { + return prepareRuntimeHostNpmGlobalStagedDeployment( + { ...input, sourcePackageRoot }, + pathOptions, + overrides.prepareDeployment, + ); + }); +} + +export async function prepareRuntimeHostNpmGlobalStagedDeployment( + input: { + readonly rootId: string; + readonly owner: RuntimeHostInstallationOwner & { readonly kind: 'cli' }; + readonly target: RuntimeHostUpdateCandidate; + readonly transactionId: string; + readonly sourcePackageRoot: string; + }, + pathOptions: RuntimeHostLocalDeploymentPathOptions = {}, + prepareDeployment: typeof prepareRuntimeHostPackageDeployment = prepareRuntimeHostPackageDeployment, ): Promise { const deploymentRoot = resolveRuntimeHostLocalCliDeploymentRoot( input.rootId, input.owner, pathOptions, ); - return overrides.withPackage(input.target, async (sourcePackageRoot) => { - const staged = await overrides.prepareDeployment({ - deploymentRoot, - sourcePackageRoot, - version: input.target.version, - packageIntegrity: input.target.integrity, - }); - const candidateEntrypoint = await requireCandidateEntrypoint(staged.packageRoot); - return { - ...staged, - candidateEntrypoint, - launchGeneration: launchGeneration(input.transactionId, input.target), - }; + const staged = await prepareDeployment({ + deploymentRoot, + sourcePackageRoot: input.sourcePackageRoot, + version: input.target.version, + packageIntegrity: input.target.integrity, }); + const candidateEntrypoint = await requireCandidateEntrypoint(staged.packageRoot); + return { + ...staged, + candidateEntrypoint, + launchGeneration: launchGeneration(input.transactionId, input.target), + }; } export function resolveRuntimeHostLocalCliDeploymentRoot( From 62a0db60ac8d3d056b0b15a31d399a95e2d9bdb5 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 27 Aug 2026 19:02:39 +0800 Subject: [PATCH 4/6] fix(cli): budget update archive expansion before extraction Registry integrity binds the downloaded .tgz to its metadata but says nothing about how far it expands: a small valid archive could exhaust the user's disk during staging or the final npm-global switch. Scan the tar headers (one pass over the compressed stream, nothing written) and refuse archives whose entries exceed a 2 GiB / 100k-entry budget before npm install consumes them, at both the verified-artifact seam and the global switch itself. Regression tests pin over-budget, over-crowded, non-gzip, and truncated archives. --- .../runtime-host-update-package.test.ts | 108 +++++++++++++++++- ...ntime-host-installed-update-coordinator.ts | 11 +- .../cli/src/runtime-host-update-package.ts | 94 +++++++++++++++ 3 files changed, 210 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-update-package.test.ts b/packages/cli/src/__tests__/runtime-host-update-package.test.ts index 7e604494ac..bbb1e95e96 100644 --- a/packages/cli/src/__tests__/runtime-host-update-package.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-package.test.ts @@ -23,14 +23,44 @@ import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; +import { gzipSync } from 'node:zlib'; import { RuntimeHostUpdatePackageError, + assertRuntimeHostArchiveExpansionBudget, withRuntimeHostRegistryUpdateArtifact, withRuntimeHostRegistryUpdatePackage, withVerifiedRuntimeHostUpdateArchive, } from '../runtime-host-update-package.js'; -const ARCHIVE = Buffer.from('verified release archive'); +function tarHeader(name: string, size: number): Buffer { + const header = Buffer.alloc(512); + header.write(name, 0, 'latin1'); + header.write('0000644\0', 100, 'latin1'); + header.write(size.toString(8).padStart(11, '0') + '\0', 124, 'latin1'); + header.write('0', 156, 'latin1'); + header.write(' ', 148, 'latin1'); + let checksum = 0; + for (const byte of header) checksum += byte; + header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 'latin1'); + return header; +} + +function tgz(entries: ReadonlyArray<{ name: string; body?: Buffer }>): Buffer { + const blocks: Buffer[] = []; + for (const entry of entries) { + const body = entry.body ?? Buffer.alloc(0); + blocks.push(tarHeader(entry.name, body.length)); + blocks.push(body); + const padding = (512 - (body.length % 512)) % 512; + if (padding > 0) blocks.push(Buffer.alloc(padding)); + } + blocks.push(Buffer.alloc(1024)); + return gzipSync(Buffer.concat(blocks)); +} + +const ARCHIVE = tgz([ + { name: 'package/package.json', body: Buffer.from('{"name":"maka-agent"}') }, +]); const INTEGRITY = `sha512-${createHash('sha512').update(ARCHIVE).digest('base64')}`; describe('managed Runtime Host update package acquisition', () => { @@ -220,4 +250,80 @@ describe('managed Runtime Host update package acquisition', () => { error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', ); }); + + it('rejects a small verified archive whose headers claim excessive expansion', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-update-budget-')); + t.after(() => rm(root, { recursive: true, force: true })); + + // One header claims 4 GiB of entry data inside a tiny compressed archive: + // integrity still matches, so only the expansion budget can stop it. + const bloated = tgz([ + { name: 'package/blob.bin', body: Buffer.from('x') }, + ]); + const bloatedHeader = tarHeader('package/huge.bin', 4 * 1024 * 1024 * 1024); + const oversized = gzipSync(Buffer.concat([bloatedHeader, Buffer.alloc(1024)])); + const crowded = tgz( + Array.from({ length: 6 }, (_, index) => ({ name: `package/file-${index}.js` })), + ); + + const oversizedPath = join(root, 'oversized.tgz'); + const crowdedPath = join(root, 'crowded.tgz'); + await Promise.all([ + writeFile(oversizedPath, oversized), + writeFile(crowdedPath, crowded), + writeFile(join(root, 'ok.tgz'), bloated), + ]); + + const budget = { maxExtractedBytes: 1024 * 1024, maxEntries: 5 }; + await assert.rejects( + assertRuntimeHostArchiveExpansionBudget(oversizedPath, budget), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', + ); + await assert.rejects( + assertRuntimeHostArchiveExpansionBudget(crowdedPath, budget), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', + ); + await assertRuntimeHostArchiveExpansionBudget(join(root, 'ok.tgz'), budget); + + // The bound runs before npm ever sees the archive: an integrity-verified + // but over-budget tarball must not reach the install spawn. + await assert.rejects( + withVerifiedRuntimeHostUpdateArchive( + { + kind: 'npm_registry', + version: '2.0.0', + integrity: `sha512-${createHash('sha512').update(oversized).digest('base64')}`, + }, + oversizedPath, + async () => assert.fail('over-budget archive must not be consumed'), + async () => assert.fail('over-budget archive must not reach npm'), + ), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', + ); + }); + + it('rejects archives that are not readable gzip tarballs', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-update-tarball-')); + t.after(() => rm(root, { recursive: true, force: true })); + const plainPath = join(root, 'plain.tgz'); + const truncatedPath = join(root, 'truncated.tgz'); + await Promise.all([ + writeFile(plainPath, Buffer.from('verified release archive')), + // A header without its terminating zero blocks. + writeFile(truncatedPath, gzipSync(tarHeader('package/package.json', 0))), + ]); + await assert.rejects( + assertRuntimeHostArchiveExpansionBudget(plainPath), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', + ); + await assert.rejects( + assertRuntimeHostArchiveExpansionBudget(truncatedPath), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', + ); + }); }); diff --git a/packages/cli/src/runtime-host-installed-update-coordinator.ts b/packages/cli/src/runtime-host-installed-update-coordinator.ts index a854f3eca3..82aa63ebe0 100644 --- a/packages/cli/src/runtime-host-installed-update-coordinator.ts +++ b/packages/cli/src/runtime-host-installed-update-coordinator.ts @@ -44,7 +44,10 @@ import { type RuntimeHostNpmGlobalInstallation, } from './runtime-host-cli-installation.js'; import type { RuntimeHostUpdateCandidate } from './runtime-host-registry-update.js'; -import { withVerifiedRuntimeHostUpdateArchive } from './runtime-host-update-package.js'; +import { + assertRuntimeHostArchiveExpansionBudget, + withVerifiedRuntimeHostUpdateArchive, +} from './runtime-host-update-package.js'; const NPM_TIMEOUT_MS = 5 * 60_000; const NPM_OUTPUT_MAX_BYTES = 64 * 1024; @@ -256,10 +259,14 @@ async function finalizeInstalledPackage( } } -export function installRuntimeHostNpmGlobalArchive( +export async function installRuntimeHostNpmGlobalArchive( archivePath: string, inheritableAuthorityLeaseFd: number, ): Promise { + // The final global switch extracts the same verified archive a second time; + // apply the expansion budget here as well so the bound holds no matter + // which caller reached this function. + await assertRuntimeHostArchiveExpansionBudget(archivePath); return new Promise((resolve, reject) => { const child = spawn( 'npm', diff --git a/packages/cli/src/runtime-host-update-package.ts b/packages/cli/src/runtime-host-update-package.ts index 195325eb2d..09fed04ad3 100644 --- a/packages/cli/src/runtime-host-update-package.ts +++ b/packages/cli/src/runtime-host-update-package.ts @@ -23,6 +23,7 @@ import { createReadStream } from 'node:fs'; import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createGunzip } from 'node:zlib'; import { isRuntimeHostNpmDeploymentIdentity } from '@maka/runtime-host/operator/update-package-evidence'; import type { RuntimeHostUpdateCandidate } from './runtime-host-registry-update.js'; @@ -32,6 +33,14 @@ const OFFLINE_REGISTRY = 'http://127.0.0.1:9/'; const NPM_TIMEOUT_MS = 5 * 60_000; const NPM_OUTPUT_MAX_BYTES = 64 * 1024; const ARCHIVE_MAX_BYTES = 256 * 1024 * 1024; +// Integrity proves the archive matches the registry metadata; it does not make +// its expansion safe. A small valid .tgz can still exhaust the user's disk +// while npm extracts it, so the tar headers are budgeted before any npm +// install consumes the archive — staging, managed extraction, and the final +// npm-global switch all pass through the same bound. +const ARCHIVE_EXTRACTED_MAX_BYTES = 2 * 1024 * 1024 * 1024; +const ARCHIVE_MAX_ENTRIES = 100_000; +const TAR_BLOCK_BYTES = 512; const MANIFEST_MAX_BYTES = 64 * 1024; export class RuntimeHostUpdatePackageError extends Error { @@ -139,6 +148,7 @@ export async function withVerifiedRuntimeHostUpdateArchive( parentTemporaryRoot ?? (await mkdtemp(join(tmpdir(), 'maka-runtime-host-update-'))); try { const archive = await validateArchive(archivePath, candidate); + await assertRuntimeHostArchiveExpansionBudget(archive); const installRoot = join(temporaryRoot, 'install'); const emptyCache = join(temporaryRoot, 'empty-cache'); const installed = await runNpm( @@ -200,6 +210,90 @@ async function validateArchive( return archive; } +export interface RuntimeHostArchiveExpansionBudget { + readonly maxExtractedBytes: number; + readonly maxEntries: number; +} + +/** + * Budget the tar headers of a verified .tgz before npm extracts it. SHA-512 + * integrity binds the archive to the registry metadata but says nothing about + * its expansion: a small valid archive can still claim gigabytes of entry + * data. Scanning the headers costs one pass over the compressed stream and + * never writes a byte, so every extraction path — staging, the managed + * prepare, and the npm-global switch — is bounded before npm runs. + */ +export async function assertRuntimeHostArchiveExpansionBudget( + archivePath: string, + budget: RuntimeHostArchiveExpansionBudget = { + maxExtractedBytes: ARCHIVE_EXTRACTED_MAX_BYTES, + maxEntries: ARCHIVE_MAX_ENTRIES, + }, +): Promise { + const fail = (message: string, options?: ErrorOptions): never => { + throw new RuntimeHostUpdatePackageError('invalid_package', message, options); + }; + const stream = createReadStream(archivePath).pipe(createGunzip()); + let pending = Buffer.alloc(0); + let entries = 0; + let extractedBytes = 0; + let ended = false; + try { + for await (const chunk of stream as AsyncIterable) { + pending = Buffer.concat([pending, chunk]); + let offset = 0; + while (pending.length - offset >= TAR_BLOCK_BYTES) { + const header = pending.subarray(offset, offset + TAR_BLOCK_BYTES); + if (isZeroBlock(header)) { + ended = true; + break; + } + const entryBytes = tarEntrySize(header, fail); + entries += 1; + extractedBytes += entryBytes; + if (entries > budget.maxEntries || extractedBytes > budget.maxExtractedBytes) { + fail('The Maka package archive exceeds its extraction budget'); + } + offset += TAR_BLOCK_BYTES + Math.ceil(entryBytes / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES; + } + if (ended) break; + pending = pending.subarray(offset); + } + } catch (error) { + if (error instanceof RuntimeHostUpdatePackageError) throw error; + fail('The Maka package archive is not a readable gzip tarball', { cause: error }); + } finally { + stream.destroy(); + } + if (!ended) { + fail('The Maka package archive is a truncated tarball'); + } +} + +function isZeroBlock(block: Buffer): boolean { + for (const byte of block) { + if (byte !== 0) return false; + } + return true; +} + +/** Octal size field at offset 124 of a tar header; base-256 (GNU) sizes are refused. */ +function tarEntrySize(header: Buffer, fail: (message: string) => never): number { + const field = header.subarray(124, 136); + if (field[0]! & 0x80) { + fail('The Maka package archive exceeds its extraction budget'); + } + const text = field.toString('latin1').replace(/\0.*$/u, '').trim(); + if (!/^[0-7]+$/u.test(text)) { + fail('The Maka package archive has a malformed tar header'); + } + const size = Number.parseInt(text, 8); + if (!Number.isSafeInteger(size)) { + fail('The Maka package archive exceeds its extraction budget'); + } + return size; +} + function assertCandidate(candidate: RuntimeHostUpdateCandidate): void { if ( !isRuntimeHostNpmDeploymentIdentity(candidate) || From 8a30232406002f7514276f643b0ff3748191b53b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 27 Aug 2026 19:50:05 +0800 Subject: [PATCH 5/6] style(cli): format the archive budget regression fixtures --- .../cli/src/__tests__/runtime-host-update-package.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-update-package.test.ts b/packages/cli/src/__tests__/runtime-host-update-package.test.ts index bbb1e95e96..d168b6b1e7 100644 --- a/packages/cli/src/__tests__/runtime-host-update-package.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-package.test.ts @@ -58,9 +58,7 @@ function tgz(entries: ReadonlyArray<{ name: string; body?: Buffer }>): Buffer { return gzipSync(Buffer.concat(blocks)); } -const ARCHIVE = tgz([ - { name: 'package/package.json', body: Buffer.from('{"name":"maka-agent"}') }, -]); +const ARCHIVE = tgz([{ name: 'package/package.json', body: Buffer.from('{"name":"maka-agent"}') }]); const INTEGRITY = `sha512-${createHash('sha512').update(ARCHIVE).digest('base64')}`; describe('managed Runtime Host update package acquisition', () => { @@ -257,9 +255,7 @@ describe('managed Runtime Host update package acquisition', () => { // One header claims 4 GiB of entry data inside a tiny compressed archive: // integrity still matches, so only the expansion budget can stop it. - const bloated = tgz([ - { name: 'package/blob.bin', body: Buffer.from('x') }, - ]); + const bloated = tgz([{ name: 'package/blob.bin', body: Buffer.from('x') }]); const bloatedHeader = tarHeader('package/huge.bin', 4 * 1024 * 1024 * 1024); const oversized = gzipSync(Buffer.concat([bloatedHeader, Buffer.alloc(1024)])); const crowded = tgz( From 3716688a1f3d1d4634295b54e0df07b084befc64 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 28 Aug 2026 12:36:29 +0800 Subject: [PATCH 6/6] fix(cli): close installed update recovery gaps --- ...me-host-installed-update-activator.test.ts | 84 +++++ ...-host-installed-update-coordinator.test.ts | 341 +++++++++++++++++- .../runtime-host-update-package.test.ts | 101 +++++- ...runtime-host-update-reconciliation.test.ts | 42 +++ packages/cli/src/cli-core.ts | 7 + packages/cli/src/runtime-host-cli.ts | 35 +- ...runtime-host-installed-update-activator.ts | 184 +++++++++- ...ntime-host-installed-update-coordinator.ts | 211 ++++++++--- .../cli/src/runtime-host-update-package.ts | 62 +++- .../fixtures/owned-authority-launcher.ts | 37 ++ .../src/__tests__/host-kernel.test.ts | 31 ++ packages/runtime-host/src/candidate-entry.ts | 9 +- .../src/candidate-launch-owner-guard.ts | 103 ++++++ .../src/client/connect-or-spawn.ts | 5 + packages/runtime-host/src/client/index.ts | 1 + packages/runtime-host/src/client/launcher.ts | 33 +- 16 files changed, 1218 insertions(+), 68 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/fixtures/owned-authority-launcher.ts create mode 100644 packages/runtime-host/src/candidate-launch-owner-guard.ts diff --git a/packages/cli/src/__tests__/runtime-host-installed-update-activator.test.ts b/packages/cli/src/__tests__/runtime-host-installed-update-activator.test.ts index 49d1880f79..fbc95536b1 100644 --- a/packages/cli/src/__tests__/runtime-host-installed-update-activator.test.ts +++ b/packages/cli/src/__tests__/runtime-host-installed-update-activator.test.ts @@ -99,6 +99,90 @@ test('reports active work and operator-owned lifecycle without forcing takeover' assert.equal(service, 4); }); +test('keeps the short-lived activator through the coordinator durable-commit boundary', async () => { + let closed = false; + let observedExpectation: + | { + readonly expectedRootId: string; + readonly ownerInstallationId: string; + readonly targetVersion: string; + readonly targetIntegrity: string; + } + | undefined; + const exitCode = await runRuntimeHostInstalledUpdateActivator( + { + rootPath: '/state', + expectedRootId: ROOT_ID, + generation: 'target-generation', + candidateEntrypoint: '/staged/candidate.js', + awaitCoordinatorCommit: true, + expectedOwnerInstallationId: 'npm-global:slot', + targetVersion: '2.0.0', + targetIntegrity: `sha512-${Buffer.alloc(64, 4).toString('base64')}`, + }, + { + connectOrSpawn: async () => ({ + kind: 'connected', + registration: registration({ generation: 'target-generation', pid: 84 }), + spawnedProcess: { pid: 84, exited: new Promise(() => undefined) }, + connection: { close: async () => (closed = true) } as never, + }), + awaitCoordinatorCommit: async (input) => { + observedExpectation = { + expectedRootId: input.expectedRootId, + ownerInstallationId: input.ownerInstallationId, + targetVersion: input.targetVersion, + targetIntegrity: input.targetIntegrity, + }; + }, + }, + ); + assert.equal(exitCode, 0); + assert.equal(closed, true); + assert.deepEqual(observedExpectation, { + expectedRootId: ROOT_ID, + ownerInstallationId: 'npm-global:slot', + targetVersion: '2.0.0', + targetIntegrity: `sha512-${Buffer.alloc(64, 4).toString('base64')}`, + }); +}); + +test('fails closed through the authenticated connection when its coordinator channel is absent', async () => { + let retirement: + | { readonly hostEpoch: string; readonly mode: 'refuse_active_work' | 'interrupt_active_work' } + | undefined; + await assert.rejects( + runRuntimeHostInstalledUpdateActivator( + { + rootPath: '/state', + expectedRootId: ROOT_ID, + generation: 'target-generation', + candidateEntrypoint: '/staged/candidate.js', + awaitCoordinatorCommit: true, + expectedOwnerInstallationId: 'npm-global:slot', + targetVersion: '2.0.0', + targetIntegrity: `sha512-${Buffer.alloc(64, 4).toString('base64')}`, + }, + { + connectOrSpawn: async () => ({ + kind: 'connected', + registration: registration({ generation: 'target-generation', pid: 84 }), + connection: { + hostEpoch: 'target-host', + close: async () => {}, + } as never, + }), + retireTarget: async (connection, mode) => { + retirement = { hostEpoch: connection.hostEpoch, mode }; + return { kind: 'prepared', pid: 84 }; + }, + }, + ), + /lost its coordinator channel/u, + ); + assert.deepEqual(retirement, { hostEpoch: 'target-host', mode: 'interrupt_active_work' }); +}); + function registration(overrides: Partial = {}): HostRegistration { return { kind: 'maka-runtime-host', diff --git a/packages/cli/src/__tests__/runtime-host-installed-update-coordinator.test.ts b/packages/cli/src/__tests__/runtime-host-installed-update-coordinator.test.ts index a828f8adb6..778c302dd5 100644 --- a/packages/cli/src/__tests__/runtime-host-installed-update-coordinator.test.ts +++ b/packages/cli/src/__tests__/runtime-host-installed-update-coordinator.test.ts @@ -18,7 +18,11 @@ */ import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; +import { gzipSync } from 'node:zlib'; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, RUNTIME_HOST_COMPATIBILITY_EPOCH, @@ -26,13 +30,29 @@ import { RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, type HostRegistration, } from '@maka/runtime-host/protocol'; -import { runRuntimeHostInstalledUpdateCoordinator } from '../runtime-host-installed-update-coordinator.js'; +import { + installRuntimeHostNpmGlobalArchive, + runRuntimeHostInstalledUpdateCoordinator, +} from '../runtime-host-installed-update-coordinator.js'; import type { RuntimeHostLocalProcessLifecycleAdapter } from '../runtime-host-local-handoff.js'; const ROOT_ID = 'b'.repeat(64); const INTEGRITY = `sha512-${Buffer.alloc(64, 4).toString('base64')}`; const OWNER = { kind: 'cli' as const, installationId: 'npm-global:slot' }; +function tarHeader(name: string, size: number, type: string): Buffer { + const header = Buffer.alloc(512); + header.write(name, 0, 'latin1'); + header.write('0000644\0', 100, 'latin1'); + header.write(size.toString(8).padStart(11, '0') + '\0', 124, 'latin1'); + header.write(type, 156, 'latin1'); + header.write(' ', 148, 'latin1'); + let checksum = 0; + for (const byte of header) checksum += byte; + header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 'latin1'); + return header; +} + test('retires with the current package, activates with the target, then switches npm before commit', async () => { const events: string[] = []; let installationRead = 0; @@ -110,7 +130,14 @@ test('retires with the current package, activates with the target, then switches events.push('activate-target'); assert.equal(input.takeoverHostEpoch, 'old-host'); assert.equal(input.inheritableAuthorityLeaseFd, 17); - return 'ready'; + assert.equal(input.ownerInstallationId, OWNER.installationId); + assert.deepEqual(input.target, target); + return { + kind: 'ready', + settle: async (outcome) => { + events.push(`settle-target:${outcome}`); + }, + }; }, installArchive: async (archivePath, inheritableAuthorityLeaseFd) => { assert.equal(archivePath, '/temporary/target.tgz'); @@ -156,10 +183,317 @@ test('retires with the current package, activates with the target, then switches 'switch-global-package', 'verify-new-installation', 'commit-owner', + 'settle-target:committed', + ]); +}); + +test('crash-retry observes its own staged target and never retires or re-activates it', async () => { + const events: string[] = []; + const oldInstallation = { + owner: OWNER, + observedRelease: { + version: '1.0.0', + packageRoot: '/global/node_modules/maka-agent', + cliPath: '/global/node_modules/maka-agent/dist/cli.js', + }, + }; + const target = { kind: 'npm_registry' as const, version: '2.0.0', integrity: INTEGRITY }; + + const exitCode = await runRuntimeHostInstalledUpdateCoordinator( + { + rootPath: '/state', + archivePath: '/temporary/target.tgz', + installedPackageRoot: oldInstallation.observedRelease.packageRoot, + installedCliPath: oldInstallation.observedRelease.cliPath, + currentVersion: oldInstallation.observedRelease.version, + target, + allowInterruptActiveTasks: true, + }, + {}, + { + resolveInstallation: async () => + events.includes('switch-global-package') + ? { + owner: OWNER, + observedRelease: { + ...oldInstallation.observedRelease, + version: target.version, + }, + } + : oldInstallation, + resolveRoot: async () => + ({ kind: 'interactive', canonicalPath: '/state', rootId: ROOT_ID }) as never, + withArchive: async (_target, archivePath, use) => + use({ archivePath, packageRoot: '/temporary/target-package' }), + prepareStaged: async () => ({ + version: target.version, + root: '/store', + packageRoot: '/store/target', + cliPath: '/store/target/dist/cli.js', + candidateEntrypoint: '/store/target/runtime-host.js', + launchGeneration: 'target-generation', + cleanup: async () => {}, + rollback: async () => {}, + }), + // The crashed first attempt already activated the staged target: the + // observed Host carries this transaction's launch generation. + connectExisting: async () => ({ + kind: 'connected', + registration: registration({ generation: 'target-generation' }), + connection: { close: async () => events.push('close-observed-target') } as never, + }), + waitForReady: async () => { + events.push('verify-observed-target-ready'); + }, + prepareRetirement: async () => { + events.push('retire'); + return { kind: 'prepared', pid: 42 }; + }, + activateTarget: async () => { + events.push('activate-target'); + return { + kind: 'ready', + settle: async (outcome) => { + events.push(`settle-target:${outcome}`); + }, + }; + }, + installArchive: async () => { + events.push('switch-global-package'); + }, + reconcile: (async (_request: unknown, lifecycle: RuntimeHostLocalProcessLifecycleAdapter) => { + assert.deepEqual( + await lifecycle.prepareHostCutover( + ROOT_ID, + target, + target, + undefined as never, + 'interrupt_active_work', + 17, + ), + { kind: 'target_present' }, + ); + await lifecycle.verifyTargetReady(ROOT_ID, target, undefined as never); + await lifecycle.finalizeTarget?.(ROOT_ID, target, undefined as never, 17); + events.push('commit-owner'); + return { + kind: 'completed', + record: { + schemaVersion: 1, + rootId: ROOT_ID, + revision: '00000000-0000-4000-8000-000000000000', + state: { kind: 'owned', owner: OWNER, selected: target }, + }, + }; + }) as never, + }, + ); + + assert.equal(exitCode, 0); + // No retirement: the live target is recognized as this transaction's own, + // while a short-lived activator re-attaches as its crash guardian through + // the pending durable commit. + assert.deepEqual(events, [ + 'close-observed-target', // preliminary observation + 'verify-observed-target-ready', + 'close-observed-target', // the prepare-phase observation of the live target + 'activate-target', + 'switch-global-package', + 'commit-owner', + 'settle-target:committed', ]); }); -function registration(): HostRegistration { +test('crash-retry with the global package already switched skips the second install', async () => { + const events: string[] = []; + const switchedInstallation = { + owner: OWNER, + observedRelease: { + version: '2.0.0', + packageRoot: '/global/node_modules/maka-agent', + cliPath: '/global/node_modules/maka-agent/dist/cli.js', + }, + }; + const target = { kind: 'npm_registry' as const, version: '2.0.0', integrity: INTEGRITY }; + + const exitCode = await runRuntimeHostInstalledUpdateCoordinator( + { + rootPath: '/state', + archivePath: '/temporary/target.tgz', + installedPackageRoot: switchedInstallation.observedRelease.packageRoot, + installedCliPath: switchedInstallation.observedRelease.cliPath, + currentVersion: switchedInstallation.observedRelease.version, + target, + allowInterruptActiveTasks: false, + }, + {}, + { + resolveInstallation: async () => switchedInstallation, + resolveRoot: async () => + ({ kind: 'interactive', canonicalPath: '/state', rootId: ROOT_ID }) as never, + withArchive: async (_target, archivePath, use) => + use({ archivePath, packageRoot: '/temporary/target-package' }), + prepareStaged: async () => ({ + version: target.version, + root: '/store', + packageRoot: '/store/target', + cliPath: '/store/target/dist/cli.js', + candidateEntrypoint: '/store/target/runtime-host.js', + launchGeneration: 'target-generation', + cleanup: async () => {}, + rollback: async () => {}, + }), + connectExisting: async () => ({ + kind: 'connected', + registration: registration({ generation: 'target-generation' }), + connection: { close: async () => events.push('close-observed-target') } as never, + }), + waitForReady: async () => {}, + prepareRetirement: async () => { + events.push('retire'); + return { kind: 'prepared', pid: 42 }; + }, + activateTarget: async () => { + events.push('activate-target'); + return { + kind: 'ready', + settle: async (outcome) => { + events.push(`settle-target:${outcome}`); + }, + }; + }, + installArchive: async () => { + events.push('switch-global-package'); + }, + reconcile: (async (_request: unknown, lifecycle: RuntimeHostLocalProcessLifecycleAdapter) => { + assert.deepEqual( + await lifecycle.prepareHostCutover( + ROOT_ID, + target, + target, + undefined as never, + 'refuse_active_work', + 17, + ), + { kind: 'target_present' }, + ); + await lifecycle.verifyTargetReady(ROOT_ID, target, undefined as never); + await lifecycle.finalizeTarget?.(ROOT_ID, target, undefined as never, 17); + events.push('commit-owner'); + return { + kind: 'completed', + record: { + schemaVersion: 1, + rootId: ROOT_ID, + revision: '00000000-0000-4000-8000-000000000000', + state: { kind: 'owned', owner: OWNER, selected: target }, + }, + }; + }) as never, + }, + ); + + assert.equal(exitCode, 0); + assert.deepEqual(events, [ + 'close-observed-target', + 'close-observed-target', + 'activate-target', + 'commit-owner', + 'settle-target:committed', + ]); +}); + +test('aborts the short-lived target activator when durable ownership cannot commit', async () => { + const events: string[] = []; + const installation = { + owner: OWNER, + observedRelease: { + version: '1.0.0', + packageRoot: '/global/node_modules/maka-agent', + cliPath: '/global/node_modules/maka-agent/dist/cli.js', + }, + }; + const target = { kind: 'npm_registry' as const, version: '2.0.0', integrity: INTEGRITY }; + const exitCode = await runRuntimeHostInstalledUpdateCoordinator( + { + rootPath: '/state', + archivePath: '/temporary/target.tgz', + installedPackageRoot: installation.observedRelease.packageRoot, + installedCliPath: installation.observedRelease.cliPath, + currentVersion: installation.observedRelease.version, + target, + allowInterruptActiveTasks: false, + }, + {}, + { + resolveInstallation: async () => installation, + resolveRoot: async () => + ({ kind: 'interactive', canonicalPath: '/state', rootId: ROOT_ID }) as never, + withArchive: async (_target, archivePath, use) => + use({ archivePath, packageRoot: '/temporary/target-package' }), + prepareStaged: async () => ({ + version: target.version, + root: '/store', + packageRoot: '/store/target', + cliPath: '/store/target/dist/cli.js', + candidateEntrypoint: '/store/target/runtime-host.js', + launchGeneration: 'target-generation', + cleanup: async () => {}, + rollback: async () => {}, + }), + connectExisting: async () => ({ kind: 'unavailable', reason: 'not_registered' }), + activateTarget: async () => ({ + kind: 'ready', + settle: async (outcome) => { + events.push(`settle-target:${outcome}`); + }, + }), + reconcile: (async (_request: unknown, lifecycle: RuntimeHostLocalProcessLifecycleAdapter) => { + assert.deepEqual( + await lifecycle.prepareUnownedHostCutover( + ROOT_ID, + target, + undefined as never, + 'refuse_active_work', + 17, + ), + { kind: 'target_present' }, + ); + return { + kind: 'recovery_required', + phase: 'commit_handoff', + cause: new Error('durability'), + }; + }) as never, + }, + ); + assert.equal(exitCode, 1); + assert.deepEqual(events, ['settle-target:abort']); +}); + +test('rejects extended tar headers before the final global npm switch can spawn', async (t) => { + const pax = Buffer.from('19 size=4294967296\n'); + const archive = gzipSync( + Buffer.concat([ + tarHeader('PaxHeader', pax.length, 'x'), + pax, + Buffer.alloc(512 - pax.length), + tarHeader('package/package.json', 0, '0'), + Buffer.alloc(1024), + ]), + ); + const root = await mkdtemp(join(tmpdir(), 'maka-global-pax-')); + t.after(() => rm(root, { recursive: true, force: true })); + const archivePath = join(root, 'pax.tgz'); + await writeFile(archivePath, archive); + await assert.rejects( + installRuntimeHostNpmGlobalArchive(archivePath, 17, (() => + assert.fail('PAX archive must not reach npm spawn')) as never), + /unsupported extended tar header/u, + ); +}); + +function registration(overrides: Partial = {}): HostRegistration { return { kind: 'maka-runtime-host', schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, @@ -175,5 +509,6 @@ function registration(): HostRegistration { state: 'ready', pid: 42, createdAt: new Date(0).toISOString(), + ...overrides, }; } diff --git a/packages/cli/src/__tests__/runtime-host-update-package.test.ts b/packages/cli/src/__tests__/runtime-host-update-package.test.ts index d168b6b1e7..cf4849623b 100644 --- a/packages/cli/src/__tests__/runtime-host-update-package.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-package.test.ts @@ -32,12 +32,12 @@ import { withVerifiedRuntimeHostUpdateArchive, } from '../runtime-host-update-package.js'; -function tarHeader(name: string, size: number): Buffer { +function tarHeader(name: string, size: number, type = '0'): Buffer { const header = Buffer.alloc(512); header.write(name, 0, 'latin1'); header.write('0000644\0', 100, 'latin1'); header.write(size.toString(8).padStart(11, '0') + '\0', 124, 'latin1'); - header.write('0', 156, 'latin1'); + header.write(type, 156, 'latin1'); header.write(' ', 148, 'latin1'); let checksum = 0; for (const byte of header) checksum += byte; @@ -45,11 +45,11 @@ function tarHeader(name: string, size: number): Buffer { return header; } -function tgz(entries: ReadonlyArray<{ name: string; body?: Buffer }>): Buffer { +function tgz(entries: ReadonlyArray<{ name: string; body?: Buffer; type?: string }>): Buffer { const blocks: Buffer[] = []; for (const entry of entries) { const body = entry.body ?? Buffer.alloc(0); - blocks.push(tarHeader(entry.name, body.length)); + blocks.push(tarHeader(entry.name, body.length, entry.type)); blocks.push(body); const padding = (512 - (body.length % 512)) % 512; if (padding > 0) blocks.push(Buffer.alloc(padding)); @@ -301,6 +301,99 @@ describe('managed Runtime Host update package acquisition', () => { ); }); + it('accepts entries whose payload spans multiple gunzip chunks', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-update-stream-')); + t.after(() => rm(root, { recursive: true, force: true })); + // A 128 KiB file entry spans many 16 KiB gunzip chunks; the scanner must + // carry the payload skip across chunk boundaries instead of reparsing + // payload bytes as the next header. + const big = tgz([ + { name: 'package/dist/big.js', body: Buffer.alloc(128 * 1024, 65) }, + { name: 'package/package.json', body: Buffer.from('{"name":"maka-agent"}') }, + { name: 'package/dist/cli.js', body: Buffer.from('#!/usr/bin/env node\n') }, + ]); + const archive = join(root, 'streamed.tgz'); + await writeFile(archive, big); + await assertRuntimeHostArchiveExpansionBudget(archive, { + maxExtractedBytes: 256 * 1024, + maxEntries: 10, + }); + // The same archive over the budget by one byte must still be caught once + // the whole payload has streamed through. + await assert.rejects( + assertRuntimeHostArchiveExpansionBudget(archive, { + maxExtractedBytes: 128 * 1024, + maxEntries: 10, + }), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', + ); + }); + + it('fails closed for tar extensions that can reinterpret a raw header size', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-update-extended-tar-')); + t.after(() => rm(root, { recursive: true, force: true })); + for (const type of ['x', 'g', 'X', 'S', 'L', 'K']) { + const archive = join(root, `${type}.tgz`); + await writeFile( + archive, + tgz([ + { name: 'extended-header', body: Buffer.from('size=4294967296\n'), type }, + { name: 'package/package.json', body: Buffer.from('{"name":"maka-agent"}') }, + ]), + ); + await assert.rejects( + assertRuntimeHostArchiveExpansionBudget(archive), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', + ); + } + // This is a syntactically valid POSIX PAX record: its decimal prefix is + // the record's complete byte length. npm would honor it to reinterpret + // the following raw tar header, so the staging seam must reject it before + // it invokes npm at all. + const pax = tgz([ + { name: 'PaxHeader', body: Buffer.from('19 size=4294967296\n'), type: 'x' }, + { name: 'package/package.json', body: Buffer.from('{"name":"maka-agent"}') }, + ]); + const paxPath = join(root, 'pax.tgz'); + await writeFile(paxPath, pax); + let invokedNpm = false; + await assert.rejects( + withVerifiedRuntimeHostUpdateArchive( + { + kind: 'npm_registry', + version: '2.0.0', + integrity: `sha512-${createHash('sha512').update(pax).digest('base64')}`, + }, + paxPath, + async () => assert.fail('extended tar archive must not be consumed'), + async () => { + invokedNpm = true; + return 0; + }, + ), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', + ); + assert.equal(invokedNpm, false); + }); + + it('requires two consecutive zero blocks to terminate a tar stream', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-update-terminator-')); + t.after(() => rm(root, { recursive: true, force: true })); + const archive = join(root, 'single-zero.tgz'); + await writeFile( + archive, + gzipSync(Buffer.concat([tarHeader('package/package.json', 0), Buffer.alloc(512)])), + ); + await assert.rejects( + assertRuntimeHostArchiveExpansionBudget(archive), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', + ); + }); + it('rejects archives that are not readable gzip tarballs', async (t) => { const root = await mkdtemp(join(tmpdir(), 'maka-update-tarball-')); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts index bc98a7e991..1935bfde79 100644 --- a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -61,6 +61,48 @@ const SERVICE = { }; describe('managed Runtime Host update reconciliation', () => { + it('parses the coordinator-supervised installed-update activator contract', () => { + const argv = [ + 'local-update-activate', + '--root', + '/srv/maka', + '--expected-root-id', + TARGET.rootId, + '--generation', + 'update-generation', + '--candidate-entrypoint', + '/srv/staged/host.js', + '--await-coordinator-commit', + 'true', + '--expected-owner-installation-id', + 'npm-global:slot', + '--target-version', + '2.0.0', + '--target-integrity', + INTEGRITY, + ]; + assert.deepEqual(parseRuntimeHostCommand(argv), { + kind: 'runtime-host-local-update-activate', + rootPath: '/srv/maka', + expectedRootId: TARGET.rootId, + generation: 'update-generation', + candidateEntrypoint: '/srv/staged/host.js', + awaitCoordinatorCommit: true, + expectedOwnerInstallationId: 'npm-global:slot', + targetVersion: '2.0.0', + targetIntegrity: INTEGRITY, + }); + const disabled = [...argv]; + disabled[disabled.indexOf('true')] = 'false'; + for (const invalid of [ + argv.filter((value) => value !== '--target-integrity' && value !== INTEGRITY), + disabled, + ['local-update-activate', ...argv.slice(1), '--target-version', '2.0.0'], + ]) { + assert.equal(parseRuntimeHostCommand(invalid).kind, 'error'); + } + }); + it('parses update policy and reconciliation commands against an optional expected target', () => { assert.deepEqual( parseRuntimeHostCommand([ diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index ae121e75d6..cc1cab4204 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -347,7 +347,14 @@ export async function runMakaCli( expectedRootId: command.expectedRootId, generation: command.generation, candidateEntrypoint: command.candidateEntrypoint, + awaitCoordinatorCommit: command.awaitCoordinatorCommit, ...(command.takeoverHostEpoch ? { takeoverHostEpoch: command.takeoverHostEpoch } : {}), + ...(command.expectedOwnerInstallationId + ? { expectedOwnerInstallationId: command.expectedOwnerInstallationId } + : {}), + ...(command.targetVersion ? { targetVersion: command.targetVersion } : {}), + ...(command.targetIntegrity ? { targetIntegrity: command.targetIntegrity } : {}), + ...(command.awaitCoordinatorCommit ? { inheritableAuthorityLeaseFd: 4 } : {}), }); } case 'runtime-host-setup': { diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index 1613386ca0..5811c85500 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -69,6 +69,10 @@ export type RuntimeHostCliCommand = generation: string; candidateEntrypoint: string; takeoverHostEpoch?: string; + awaitCoordinatorCommit: boolean; + expectedOwnerInstallationId?: string; + targetVersion?: string; + targetIntegrity?: string; } | { kind: 'runtime-host-serve'; @@ -419,6 +423,10 @@ function parseLocalUpdateActivate(argv: string[]): RuntimeHostCliCommand { '--generation', '--candidate-entrypoint', '--takeover-host-epoch', + '--await-coordinator-commit', + '--expected-owner-installation-id', + '--target-version', + '--target-integrity', ]); for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; @@ -434,6 +442,10 @@ function parseLocalUpdateActivate(argv: string[]): RuntimeHostCliCommand { const generation = values.get('--generation'); const candidateEntrypoint = values.get('--candidate-entrypoint'); const takeoverHostEpoch = values.get('--takeover-host-epoch'); + const awaitCoordinatorCommit = values.get('--await-coordinator-commit') === 'true'; + const expectedOwnerInstallationId = values.get('--expected-owner-installation-id'); + const targetVersion = values.get('--target-version'); + const targetIntegrity = values.get('--target-integrity'); if (!rootPath || !expectedRootId || !generation || !candidateEntrypoint) { return error('runtime-host local-update-activate requires its exact target identity'); } @@ -444,17 +456,38 @@ function parseLocalUpdateActivate(argv: string[]): RuntimeHostCliCommand { return error('runtime-host local-update-activate root identity is invalid'); } if ( - [generation, takeoverHostEpoch].some((value) => value !== undefined && !isSafeIdentity(value)) + [ + generation, + takeoverHostEpoch, + expectedOwnerInstallationId, + targetVersion, + targetIntegrity, + ].some((value) => value !== undefined && !isSafeIdentity(value)) ) { return error('runtime-host local-update-activate generation is invalid'); } + if ( + (values.has('--await-coordinator-commit') && !awaitCoordinatorCommit) || + (awaitCoordinatorCommit && + (!expectedOwnerInstallationId || !targetVersion || !targetIntegrity)) || + (!awaitCoordinatorCommit && + (expectedOwnerInstallationId !== undefined || + targetVersion !== undefined || + targetIntegrity !== undefined)) + ) { + return error('runtime-host local-update-activate coordinator expectation is invalid'); + } return { kind: 'runtime-host-local-update-activate', rootPath, expectedRootId, generation, candidateEntrypoint, + awaitCoordinatorCommit, ...(takeoverHostEpoch ? { takeoverHostEpoch } : {}), + ...(expectedOwnerInstallationId ? { expectedOwnerInstallationId } : {}), + ...(targetVersion ? { targetVersion } : {}), + ...(targetIntegrity ? { targetIntegrity } : {}), }; } diff --git a/packages/cli/src/runtime-host-installed-update-activator.ts b/packages/cli/src/runtime-host-installed-update-activator.ts index ef4c4cec27..579e2e8841 100644 --- a/packages/cli/src/runtime-host-installed-update-activator.ts +++ b/packages/cli/src/runtime-host-installed-update-activator.ts @@ -18,7 +18,15 @@ */ import { randomUUID } from 'node:crypto'; -import { connectOrSpawnRuntimeHost, runtimeHostStartupError } from '@maka/runtime-host/client'; +import { + connectOrSpawnRuntimeHost, + createRuntimeHostCandidateLaunchBarrier, + prepareConnectedRuntimeHostRetirement, + runtimeHostStartupError, + type RuntimeHostCandidateLaunchBarrier, + type RuntimeHostConnection, +} from '@maka/runtime-host/client'; +import { readLocalHostDeploymentRecord } from '@maka/runtime-host/operator'; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, RUNTIME_HOST_PROTOCOL_VERSION, @@ -31,10 +39,26 @@ export async function runRuntimeHostInstalledUpdateActivator( readonly generation: string; readonly candidateEntrypoint: string; readonly takeoverHostEpoch?: string; + /** The coordinator keeps this short-lived activator alive through durable commit. */ + readonly awaitCoordinatorCommit?: boolean; + readonly expectedOwnerInstallationId?: string; + readonly targetVersion?: string; + readonly targetIntegrity?: string; + /** fd 4 inherited from the coordinator's existing authority transaction. */ + readonly inheritableAuthorityLeaseFd?: number; }, - overrides: { readonly connectOrSpawn?: typeof connectOrSpawnRuntimeHost } = {}, + overrides: { + readonly connectOrSpawn?: typeof connectOrSpawnRuntimeHost; + readonly createLaunchBarrier?: typeof createRuntimeHostCandidateLaunchBarrier; + readonly awaitCoordinatorCommit?: typeof awaitCoordinatorCommit; + readonly retireTarget?: typeof prepareConnectedRuntimeHostRetirement; + readonly readRecord?: typeof readLocalHostDeploymentRecord; + } = {}, ): Promise { - const result = await (overrides.connectOrSpawn ?? connectOrSpawnRuntimeHost)({ + const launchBarrier = ( + overrides.createLaunchBarrier ?? createRuntimeHostCandidateLaunchBarrier + )(); + const result = await (overrides.connectOrSpawn ?? ((request) => launchBarrier.connect(request)))({ rootPath: input.rootPath, protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, @@ -42,8 +66,13 @@ export async function runRuntimeHostInstalledUpdateActivator( ...(input.takeoverHostEpoch ? { takeoverHostEpoch: input.takeoverHostEpoch } : {}), clientInstanceId: randomUUID(), candidateEntrypoint: input.candidateEntrypoint, + ...(input.inheritableAuthorityLeaseFd === undefined + ? {} + : { inheritableAuthorityLeaseFd: input.inheritableAuthorityLeaseFd }), }); if (result.kind === 'connected') { + let exactTarget = false; + let commitWaitOwnsAbort = false; try { if ( result.registration.rootId !== input.expectedRootId || @@ -53,14 +82,163 @@ export async function runRuntimeHostInstalledUpdateActivator( ) { throw new Error('The activated Runtime Host does not match the exact staged target'); } + exactTarget = true; + if (input.awaitCoordinatorCommit) { + if (!input.expectedOwnerInstallationId || !input.targetVersion || !input.targetIntegrity) { + throw new Error('The activator is missing its exact durable commit expectation'); + } + commitWaitOwnsAbort = true; + await (overrides.awaitCoordinatorCommit ?? awaitCoordinatorCommit)({ + registration: result.registration, + connection: result.connection, + expectedRootId: input.expectedRootId, + ownerInstallationId: input.expectedOwnerInstallationId, + targetVersion: input.targetVersion, + targetIntegrity: input.targetIntegrity, + ownsCandidate: result.spawnedProcess !== undefined, + launchBarrier, + retireTarget: overrides.retireTarget ?? prepareConnectedRuntimeHostRetirement, + readRecord: overrides.readRecord ?? readLocalHostDeploymentRecord, + }); + } else { + launchBarrier.release(); + } return 0; + } catch (error) { + if (exactTarget && !commitWaitOwnsAbort) { + await retireUncommittedTarget({ + connection: result.connection, + ownsCandidate: result.spawnedProcess !== undefined, + launchBarrier, + retireTarget: overrides.retireTarget ?? prepareConnectedRuntimeHostRetirement, + }).catch(() => undefined); + } + throw error; } finally { await result.connection.close().catch(() => undefined); } } + await retireOwnedCandidates(launchBarrier).catch(() => undefined); if (result.kind === 'failed') { throw runtimeHostStartupError(result.reason, result.diagnostic); } if (result.registration.lifecycleMode !== 'ephemeral') return 4; return 3; } + +interface CoordinatorCommitWaitInput { + readonly registration: { readonly pid: number }; + readonly connection: RuntimeHostConnection; + readonly expectedRootId: string; + readonly ownerInstallationId: string; + readonly targetVersion: string; + readonly targetIntegrity: string; + readonly ownsCandidate: boolean; + readonly launchBarrier: RuntimeHostCandidateLaunchBarrier; + readonly retireTarget: typeof prepareConnectedRuntimeHostRetirement; + readonly readRecord: typeof readLocalHostDeploymentRecord; +} + +type UncommittedTargetInput = Pick< + CoordinatorCommitWaitInput, + 'connection' | 'ownsCandidate' | 'launchBarrier' | 'retireTarget' +>; + +/** + * The coordinator owns the durable authority transaction, while this child + * holds its inherited lease until that transaction commits. If the coordinator + * disappears, read the durable record before deciding whether the target is an + * orphan. That closes the commit-before-ack race without handing the lease to + * the long-lived Runtime Host. + */ +async function awaitCoordinatorCommit(input: CoordinatorCommitWaitInput): Promise { + if (typeof process.send !== 'function' || !process.connected) { + await retireUncommittedTarget(input); + throw new Error('The installed update activator lost its coordinator channel'); + } + await new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + process.off('message', onMessage); + process.off('disconnect', onDisconnect); + }; + const settle = (operation: () => Promise) => { + if (settled) return; + settled = true; + cleanup(); + void operation().then(resolve, reject); + }; + const onMessage = (message: unknown) => { + if (!isCoordinatorMessage(message)) return; + if (message.kind === 'committed') { + settle(async () => { + if (!(await isCommittedTarget(input))) { + await retireUncommittedTarget(input); + throw new Error( + 'The target activation was acknowledged before durable ownership committed', + ); + } + input.launchBarrier.release(); + }); + } + if (message.kind === 'abort') { + settle(async () => { + await retireUncommittedTarget(input); + throw new Error( + 'The installed update coordinator aborted before durable ownership committed', + ); + }); + } + }; + const onDisconnect = () => { + settle(async () => { + if (await isCommittedTarget(input)) return; + await retireUncommittedTarget(input); + throw new Error( + 'The installed update coordinator exited before durable ownership committed', + ); + }); + }; + process.on('message', onMessage); + process.once('disconnect', onDisconnect); + process.send?.({ kind: 'ready' }); + }); +} + +function isCoordinatorMessage(value: unknown): value is { readonly kind: 'committed' | 'abort' } { + return ( + typeof value === 'object' && + value !== null && + (value as { kind?: unknown }).kind !== undefined && + ((value as { kind?: unknown }).kind === 'committed' || + (value as { kind?: unknown }).kind === 'abort') + ); +} + +async function isCommittedTarget(input: CoordinatorCommitWaitInput): Promise { + const record = await input.readRecord(input.expectedRootId); + return ( + record?.state.kind === 'owned' && + record.state.owner.kind === 'cli' && + record.state.owner.installationId === input.ownerInstallationId && + record.state.selected.kind === 'npm_registry' && + record.state.selected.version === input.targetVersion && + record.state.selected.integrity === input.targetIntegrity + ); +} + +async function retireUncommittedTarget(input: UncommittedTargetInput): Promise { + if (input.ownsCandidate) { + await retireOwnedCandidates(input.launchBarrier); + return; + } + const retirement = await input.retireTarget(input.connection, 'interrupt_active_work'); + if (retirement.kind !== 'prepared') { + throw new Error('The uncommitted Runtime Host would not accept exact retirement'); + } +} + +async function retireOwnedCandidates(barrier: RuntimeHostCandidateLaunchBarrier): Promise { + barrier.pause(); + await barrier.retireExcept(-1); +} diff --git a/packages/cli/src/runtime-host-installed-update-coordinator.ts b/packages/cli/src/runtime-host-installed-update-coordinator.ts index 82aa63ebe0..7b083528a0 100644 --- a/packages/cli/src/runtime-host-installed-update-coordinator.ts +++ b/packages/cli/src/runtime-host-installed-update-coordinator.ts @@ -25,6 +25,7 @@ import { pathToFileURL } from 'node:url'; import { connectExistingRuntimeHost, prepareConnectedRuntimeHostRetirement, + waitForRuntimeHostReady, type RuntimeHostConnection, } from '@maka/runtime-host/client'; import { type LocalHostDeploymentAuthorityOptions } from '@maka/runtime-host/operator'; @@ -57,13 +58,14 @@ interface RuntimeHostInstalledUpdateCoordinatorDeps { readonly resolveInstallation: typeof resolveRuntimeHostNpmGlobalInstallation; readonly resolveRoot: typeof resolveStorageRoot; readonly connectExisting: typeof connectExistingRuntimeHost; + readonly waitForReady: typeof waitForRuntimeHostReady; readonly prepareRetirement: typeof prepareConnectedRuntimeHostRetirement; readonly withArchive: typeof withVerifiedRuntimeHostUpdateArchive; readonly prepareStaged: typeof prepareRuntimeHostNpmGlobalStagedDeployment; readonly reconcile: typeof reconcilePreparedRuntimeHostNpmGlobalDeployment; readonly activateTarget: ( input: RuntimeHostTargetActivationInput, - ) => Promise<'ready' | 'active_work' | 'operator_required'>; + ) => Promise; readonly installArchive: typeof installRuntimeHostNpmGlobalArchive; } @@ -71,10 +73,18 @@ interface RuntimeHostTargetActivationInput { readonly rootPath: string; readonly rootId: string; readonly staged: RuntimeHostLocalStagedDeployment; + readonly ownerInstallationId: string; + readonly target: RuntimeHostUpdateCandidate; readonly takeoverHostEpoch?: string; readonly inheritableAuthorityLeaseFd: number; } +interface RuntimeHostTargetActivation { + readonly kind: 'ready' | 'active_work' | 'operator_required'; + /** Releases the short-lived child only after durable owner settlement. */ + settle(outcome: 'committed' | 'abort'): Promise; +} + export interface RuntimeHostInstalledUpdateCoordinatorInput { readonly rootPath: string; readonly archivePath: string; @@ -94,6 +104,7 @@ export async function runRuntimeHostInstalledUpdateCoordinator( resolveInstallation: resolveRuntimeHostNpmGlobalInstallation, resolveRoot: resolveStorageRoot, connectExisting: connectExistingRuntimeHost, + waitForReady: waitForRuntimeHostReady, prepareRetirement: prepareConnectedRuntimeHostRetirement, withArchive: withVerifiedRuntimeHostUpdateArchive, prepareStaged: prepareRuntimeHostNpmGlobalStagedDeployment, @@ -130,11 +141,54 @@ export async function runRuntimeHostInstalledUpdateCoordinator( await preliminary.connection?.close(); let observation: Awaited> = {}; let targetReady = false; + let targetActivator: RuntimeHostTargetActivation | undefined; + const activateExactTarget = async ( + inheritableAuthorityLeaseFd: number, + takeoverHostEpoch?: string, + ): Promise<'target_present' | 'active_work' | 'operator_required'> => { + const activated = await deps.activateTarget({ + rootPath: input.rootPath, + rootId: root.rootId, + staged, + ownerInstallationId: installation.owner.installationId, + target: input.target, + inheritableAuthorityLeaseFd, + ...(takeoverHostEpoch ? { takeoverHostEpoch } : {}), + }); + if (activated.kind === 'operator_required') return 'operator_required'; + if (activated.kind === 'active_work') return 'active_work'; + targetActivator = activated; + targetReady = true; + return 'target_present'; + }; const prepare = async (inheritableAuthorityLeaseFd: number) => { observation = await observeCurrentHost(input.rootPath, root.rootId, deps); if (observation.registration && observation.registration.lifecycleMode !== 'ephemeral') { throw new Error('Only an ephemeral local Runtime Host can be updated by this CLI'); } + // Crash-retry after activation: the observed Host may already be this + // transaction's staged target (the generation is derived from the + // transaction id, so a retry stages the same one). Retiring it would + // either kill the live target or, when it holds active work, drive the + // handoff's active-work rollback that re-selects the retired Host while + // the target keeps running — durable record and reality would diverge. + if (observation.registration?.generation === staged.launchGeneration) { + if (!observation.connection) { + throw new Error('The exact staged Runtime Host is not connected for Ready verification'); + } + try { + await deps.waitForReady(observation.connection); + } finally { + await observation.connection.close(); + } + observation = { registration: observation.registration }; + const activated = await activateExactTarget(inheritableAuthorityLeaseFd); + if (activated === 'operator_required') { + throw new Error('The observed Runtime Host requires its operator to perform the update'); + } + if (activated === 'active_work') return { kind: 'active_work' as const }; + return { kind: 'target_present' as const }; + } const takeoverHostEpoch = observation.registration?.hostEpoch; if (observation.connection) { const retirement = await deps.prepareRetirement( @@ -145,57 +199,59 @@ export async function runRuntimeHostInstalledUpdateCoordinator( await observation.connection.close(); observation = { registration: observation.registration }; } - const activated = await deps.activateTarget({ - rootPath: input.rootPath, - rootId: root.rootId, - staged, - inheritableAuthorityLeaseFd, - ...(takeoverHostEpoch ? { takeoverHostEpoch } : {}), - }); + const activated = await activateExactTarget(inheritableAuthorityLeaseFd, takeoverHostEpoch); if (activated === 'operator_required') { throw new Error('The observed Runtime Host requires its operator to perform the update'); } if (activated === 'active_work') return { kind: 'active_work' as const }; - targetReady = true; return { kind: 'target_present' as const }; }; const unreachable = async (): Promise => { throw new Error('The exact target activator must settle local Host cutover'); }; - const result = await deps.reconcile( - { - rootId: root.rootId, - transactionId, - target: input.target, - activeWorkPolicy: input.allowInterruptActiveTasks - ? 'interrupt_active_work' - : 'refuse_active_work', - installation, - staged, - }, - { - prepareUnownedHostCutover: (_rootId, _target, _staged, _policy, leaseFd) => - prepare(leaseFd), - prepareHostCutover: (_rootId, _selected, _target, _staged, _policy, leaseFd) => - prepare(leaseFd), - observeWriterRelease: unreachable, - activateTarget: unreachable, - async verifyTargetReady() { - if (!targetReady) throw new Error('The exact target Ready evidence is unavailable'); + let result: Awaited>; + try { + result = await deps.reconcile( + { + rootId: root.rootId, + transactionId, + target: input.target, + activeWorkPolicy: input.allowInterruptActiveTasks + ? 'interrupt_active_work' + : 'refuse_active_work', + installation, + staged, }, - async finalizeTarget(_rootId, _target, _staged, inheritableAuthorityLeaseFd) { - await finalizeInstalledPackage( - input, - installation, - archivePath, - installationOptions, - deps, - inheritableAuthorityLeaseFd, - ); + { + prepareUnownedHostCutover: (_rootId, _target, _staged, _policy, leaseFd) => + prepare(leaseFd), + prepareHostCutover: (_rootId, _selected, _target, _staged, _policy, leaseFd) => + prepare(leaseFd), + observeWriterRelease: unreachable, + activateTarget: unreachable, + async verifyTargetReady() { + if (!targetReady) throw new Error('The exact target Ready evidence is unavailable'); + }, + async finalizeTarget(_rootId, _target, _staged, inheritableAuthorityLeaseFd) { + await finalizeInstalledPackage( + input, + installation, + archivePath, + installationOptions, + deps, + inheritableAuthorityLeaseFd, + ); + }, }, - }, - authorityOptions, - ); + authorityOptions, + ); + if (result.kind === 'completed' && targetActivator) { + await targetActivator.settle('committed'); + targetActivator = undefined; + } + } finally { + if (targetActivator) await targetActivator.settle('abort').catch(() => undefined); + } if (result.kind === 'completed') { process.stdout.write(`Updated Maka to ${input.target.version}.\n`); return 0; @@ -262,13 +318,14 @@ async function finalizeInstalledPackage( export async function installRuntimeHostNpmGlobalArchive( archivePath: string, inheritableAuthorityLeaseFd: number, + spawnProcess: typeof spawn = spawn, ): Promise { // The final global switch extracts the same verified archive a second time; // apply the expansion budget here as well so the bound holds no matter // which caller reached this function. await assertRuntimeHostArchiveExpansionBudget(archivePath); return new Promise((resolve, reject) => { - const child = spawn( + const child = spawnProcess( 'npm', [ 'install', @@ -310,7 +367,7 @@ export async function installRuntimeHostNpmGlobalArchive( function launchTargetActivator( input: RuntimeHostTargetActivationInput, -): Promise<'ready' | 'active_work' | 'operator_required'> { +): Promise { const args = [ input.staged.cliPath, 'runtime-host', @@ -323,24 +380,78 @@ function launchTargetActivator( input.staged.launchGeneration, '--candidate-entrypoint', input.staged.candidateEntrypoint, + '--await-coordinator-commit', + 'true', + '--expected-owner-installation-id', + input.ownerInstallationId, + '--target-version', + input.target.version, + '--target-integrity', + input.target.integrity, ...(input.takeoverHostEpoch ? ['--takeover-host-epoch', input.takeoverHostEpoch] : []), ]; return new Promise((resolve, reject) => { const child = spawn(process.execPath, args, { - stdio: ['inherit', 'inherit', 'inherit', input.inheritableAuthorityLeaseFd], + // fd 3 is the coordinator channel; fd 4 is the inherited authority + // lease. The activator, not the long-lived target, owns that lease. + stdio: ['inherit', 'inherit', 'inherit', 'ipc', input.inheritableAuthorityLeaseFd], windowsHide: false, }); + let ready = false; + let settled = false; + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolveClosed) => { + child.once('close', (code, signal) => resolveClosed({ code, signal })); + }, + ); + const closeError = async (): Promise => { + const { code, signal } = await closed; + if (signal) throw new Error(`Maka target activator exited on ${signal}`); + if (code === 3) throw new Error('The activated Runtime Host still owns active work'); + if (code === 4) throw new Error('The observed Runtime Host requires its operator'); + throw new Error('The exact Maka target could not be activated'); + }; + const settle = async (outcome: 'committed' | 'abort'): Promise => { + if (settled) return; + settled = true; + if (child.connected) { + await new Promise((resolveSent, rejectSent) => { + child.send({ kind: outcome }, (error) => (error ? rejectSent(error) : resolveSent())); + }); + } + const exited = await closed; + if (outcome === 'committed' && (exited.signal || exited.code !== 0)) { + if (exited.signal) throw new Error(`Maka target activator exited on ${exited.signal}`); + throw new Error('The exact Maka target activator did not confirm durable ownership'); + } + }; child.once('error', reject); - child.once('close', (code, signal) => { - if (signal) reject(new Error(`Maka target activator exited on ${signal}`)); - else if (code === 0) resolve('ready'); - else if (code === 3) resolve('active_work'); - else if (code === 4) resolve('operator_required'); - else reject(new Error('The exact Maka target could not be activated')); + child.on('message', (message: unknown) => { + if (ready || !isTargetActivatorReadyMessage(message)) return; + ready = true; + resolve({ kind: 'ready', settle }); + }); + void closed.then(({ code, signal }) => { + if (ready) return; + if (signal) { + reject(new Error(`Maka target activator exited on ${signal}`)); + } else if (code === 3) { + resolve({ kind: 'active_work', settle: async () => undefined }); + } else if (code === 4) { + resolve({ kind: 'operator_required', settle: async () => undefined }); + } else { + void closeError().catch(reject); + } }); }); } +function isTargetActivatorReadyMessage(value: unknown): value is { readonly kind: 'ready' } { + return ( + typeof value === 'object' && value !== null && (value as { kind?: unknown }).kind === 'ready' + ); +} + function updateTransactionId( rootId: string, installation: RuntimeHostNpmGlobalInstallation, diff --git a/packages/cli/src/runtime-host-update-package.ts b/packages/cli/src/runtime-host-update-package.ts index 09fed04ad3..a9d0c7282b 100644 --- a/packages/cli/src/runtime-host-update-package.ts +++ b/packages/cli/src/runtime-host-update-package.ts @@ -235,19 +235,46 @@ export async function assertRuntimeHostArchiveExpansionBudget( }; const stream = createReadStream(archivePath).pipe(createGunzip()); let pending = Buffer.alloc(0); + // Payload bytes of the current entry that have not arrived yet. Header + // bookkeeping advances past an entry's payload whether or not the payload + // is already buffered, so the deficit must be carried across chunks — + // otherwise the payload of any entry larger than one gunzip chunk would be + // reparsed as the next header. + let skipBytes = 0; let entries = 0; let extractedBytes = 0; + let zeroBlocks = 0; let ended = false; try { for await (const chunk of stream as AsyncIterable) { pending = Buffer.concat([pending, chunk]); + if (skipBytes > 0) { + if (pending.length <= skipBytes) { + skipBytes -= pending.length; + // The chunk is fully consumed payload; drop it before the next + // concat or already-skipped bytes would be counted twice. + pending = Buffer.alloc(0); + continue; + } + pending = pending.subarray(skipBytes); + skipBytes = 0; + } let offset = 0; while (pending.length - offset >= TAR_BLOCK_BYTES) { const header = pending.subarray(offset, offset + TAR_BLOCK_BYTES); if (isZeroBlock(header)) { - ended = true; - break; + zeroBlocks += 1; + offset += TAR_BLOCK_BYTES; + if (zeroBlocks === 2) { + ended = true; + break; + } + continue; } + // POSIX tar uses two consecutive zero blocks as its only terminator. + // Do not accept a stream that resumes after the first terminator block. + if (zeroBlocks !== 0) fail('The Maka package archive has a malformed tar terminator'); + assertSupportedTarType(header, fail); const entryBytes = tarEntrySize(header, fail); entries += 1; extractedBytes += entryBytes; @@ -255,6 +282,11 @@ export async function assertRuntimeHostArchiveExpansionBudget( fail('The Maka package archive exceeds its extraction budget'); } offset += TAR_BLOCK_BYTES + Math.ceil(entryBytes / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES; + if (offset > pending.length) { + skipBytes = offset - pending.length; + offset = pending.length; + break; + } } if (ended) break; pending = pending.subarray(offset); @@ -277,6 +309,32 @@ function isZeroBlock(block: Buffer): boolean { return true; } +/** + * This is a budget scanner, not a second tar implementation. Extended tar + * headers can override the raw size field that the scanner sees, so reject + * them rather than claiming a bound we cannot prove. Accept only standard + * ustar entry kinds whose payload size is carried in this header. + */ +function assertSupportedTarType(header: Buffer, fail: (message: string) => never): void { + const type = header[156] ?? 0; + if ( + type === 0 || + type === 48 || // regular file + type === 49 || // hard link + type === 50 || // symbolic link + type === 51 || // character device + type === 52 || // block device + type === 53 || // directory + type === 54 || // FIFO + type === 55 // contiguous file + ) { + return; + } + // In particular: x/g/X (PAX) and S (GNU sparse) can reinterpret payload + // sizes, while L/K can reinterpret paths. Refuse all extended variants. + fail('The Maka package archive uses an unsupported extended tar header'); +} + /** Octal size field at offset 124 of a tar header; base-256 (GNU) sizes are refused. */ function tarEntrySize(header: Buffer, fail: (message: string) => never): number { const field = header.subarray(124, 136); diff --git a/packages/runtime-host/src/__tests__/fixtures/owned-authority-launcher.ts b/packages/runtime-host/src/__tests__/fixtures/owned-authority-launcher.ts new file mode 100644 index 0000000000..2d85f37d26 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/owned-authority-launcher.ts @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { openSync } from 'node:fs'; +import { launchOwnedRuntimeHostCandidate } from '../../client/launcher.js'; + +const [rootPath, expectedRootId, leasePath] = process.argv.slice(2); +if (!rootPath || !expectedRootId || !leasePath) { + throw new Error('usage: owned-authority-launcher '); +} + +const leaseFd = openSync(leasePath, 'a+'); +const attempt = await launchOwnedRuntimeHostCandidate({ + rootPath, + expectedRootId, + entrypoint: new URL('../../execution-candidate-main.js', import.meta.url), + idleGraceMs: 10_000, + inheritableAuthorityLeaseFd: leaseFd, +}).spawned; +process.send?.({ type: 'launched', pid: attempt.pid }); +await new Promise(() => undefined); diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 2d33a50267..cfd6ecd13e 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -1814,6 +1814,37 @@ describe('non-serving Runtime Host kernel', () => { }); }); + test('an authority-supervised Candidate exits if its launch owner is killed', async () => { + await withHostPaths(async (paths) => { + const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); + const launcher = paths.resources.trackChild( + fork( + new URL('./fixtures/owned-authority-launcher.js', import.meta.url), + [paths.root, capability.rootId, join(paths.base, 'authority-lease-probe')], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ), + ); + const launchedPid = paths.resources.trackPid(await waitForLaunch(launcher)); + let connected = await retryConnect(paths, CURRENT_PROTOCOL); + if (connected.kind !== 'connected') { + connected = await retryConnect(paths, CURRENT_PROTOCOL); + } + assert.equal(connected.kind, 'connected'); + if (connected.kind !== 'connected') return; + assert.equal(connected.registration.pid, launchedPid); + + launcher.kill('SIGKILL'); + await waitForExit(launcher); + await withTimeout( + connected.connection.closed, + 5_000, + 'authority-supervised Candidate survived its launch owner', + ); + await waitForProcessExit(launchedPid); + paths.resources.forgetPid(launchedPid); + }); + }); + test('a detached Candidate survives writing stderr after its launcher exits', async () => { await withHostPaths(async (paths) => { const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); diff --git a/packages/runtime-host/src/candidate-entry.ts b/packages/runtime-host/src/candidate-entry.ts index 8ea6bcc8f0..ba46642f1b 100644 --- a/packages/runtime-host/src/candidate-entry.ts +++ b/packages/runtime-host/src/candidate-entry.ts @@ -23,6 +23,7 @@ import { candidateStartupFailureExitCode, classifyCandidateStartupFailure, } from './candidate-startup-failure.js'; +import { createRuntimeHostLaunchOwnerGuard } from './candidate-launch-owner-guard.js'; import { parseInteractiveRuntimeHostCandidateArguments } from './candidate-cli.js'; import { writeCandidateStartupDiagnostic } from './control/startup-diagnostic.js'; import { installRuntimeHostLogCapture, runtimeHostLogBuffer } from './process-diagnostics.js'; @@ -56,6 +57,7 @@ export async function runExecutionCandidateEntry( hooks: ExecutionCandidateEntryHooks = {}, ): Promise { installRuntimeHostLogCapture(); + const launchOwnerGuard = createRuntimeHostLaunchOwnerGuard(); let result: Awaited>; let rootId: string | undefined; @@ -90,8 +92,12 @@ export async function runExecutionCandidateEntry( } process.exit(candidateStartupFailureExitCode(failure)); } - if (result.kind === 'loser') process.exit(2); + if (result.kind === 'loser') { + await launchOwnerGuard?.dispose(); + process.exit(2); + } + launchOwnerGuard?.bind(() => result.host.close()); const stopWatch = hooks.onWon?.(result.host); try { await runRuntimeHostProcessLifecycle(result.host); @@ -104,5 +110,6 @@ export async function runExecutionCandidateEntry( process.exitCode = 1; } finally { stopWatch?.(); + await launchOwnerGuard?.dispose(); } } diff --git a/packages/runtime-host/src/candidate-launch-owner-guard.ts b/packages/runtime-host/src/candidate-launch-owner-guard.ts new file mode 100644 index 0000000000..839f78e166 --- /dev/null +++ b/packages/runtime-host/src/candidate-launch-owner-guard.ts @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { closeSync } from 'node:fs'; + +export const RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV = 'MAKA_RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD'; +export const RUNTIME_HOST_LAUNCH_OWNER_RELEASE_KIND = 'runtime-host-launch-owner-release'; + +export interface RuntimeHostLaunchOwnerGuard { + bind(closeHost: () => Promise): void; + dispose(): Promise; +} + +/** + * Keeps the updater's authority lease inside a Candidate until its launcher + * explicitly releases it. Launcher loss closes the Host before the lease, so + * no second owner can enter while the uncommitted target remains a writer. + */ +export function createRuntimeHostLaunchOwnerGuard( + env: NodeJS.ProcessEnv = process.env, +): RuntimeHostLaunchOwnerGuard | undefined { + const rawFd = env[RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV]; + if (rawFd === undefined) return undefined; + const leaseFd = Number(rawFd); + if (!Number.isSafeInteger(leaseFd) || leaseFd < 3) { + throw new Error('Runtime Host launch-owner authority descriptor is invalid'); + } + + let state: 'owned' | 'released' | 'lost' = process.connected ? 'owned' : 'lost'; + let closeHost: (() => Promise) | undefined; + let lossSettlement: Promise | undefined; + let leaseClosed = false; + + const closeLease = () => { + if (leaseClosed) return; + leaseClosed = true; + closeSync(leaseFd); + }; + const settleLoss = () => { + if (state !== 'lost' || !closeHost || lossSettlement) return; + lossSettlement = Promise.resolve() + .then(closeHost) + .then( + () => undefined, + () => undefined, + ) + .finally(closeLease); + }; + const onMessage = (message: unknown) => { + if ( + state !== 'owned' || + typeof message !== 'object' || + message === null || + (message as { kind?: unknown }).kind !== RUNTIME_HOST_LAUNCH_OWNER_RELEASE_KIND + ) { + return; + } + state = 'released'; + closeLease(); + }; + const onDisconnect = () => { + if (state !== 'owned') return; + state = 'lost'; + settleLoss(); + }; + process.on('message', onMessage); + process.once('disconnect', onDisconnect); + + return { + bind(close) { + closeHost = close; + settleLoss(); + }, + async dispose() { + process.off('message', onMessage); + process.off('disconnect', onDisconnect); + await lossSettlement; + closeLease(); + }, + }; +} + +export function runtimeHostLaunchOwnerReleaseMessage(): { + readonly kind: typeof RUNTIME_HOST_LAUNCH_OWNER_RELEASE_KIND; +} { + return { kind: RUNTIME_HOST_LAUNCH_OWNER_RELEASE_KIND }; +} diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts index 97649387f8..77205ac99c 100644 --- a/packages/runtime-host/src/client/connect-or-spawn.ts +++ b/packages/runtime-host/src/client/connect-or-spawn.ts @@ -86,6 +86,8 @@ export interface ConnectOrSpawnRuntimeHostInput { candidateEntrypoint: string | URL; managedLaunchClaim?: RuntimeHostManagedLaunchClaim; signal?: AbortSignal; + /** Existing authority lease inherited by a launch-owner-supervised Candidate. */ + inheritableAuthorityLeaseFd?: number; /** Candidate-exit sink forwarded to the launcher; the embedder owns the sink. */ onExit?: (details: CandidateExitDetails) => void; } @@ -457,6 +459,9 @@ export async function connectOrSpawnRuntimeHostWithDependencies( ...(input.generation === undefined ? {} : { generation: input.generation }), ...(managedLaunchClaim === undefined ? {} : { managedLaunchClaim }), ...(input.onExit === undefined ? {} : { onExit: input.onExit }), + ...(input.inheritableAuthorityLeaseFd === undefined + ? {} + : { inheritableAuthorityLeaseFd: input.inheritableAuthorityLeaseFd }), }); candidateLaunches.add(launch); const attempt = await settleBeforeDeadline(launch.spawned, deadline, input.signal); diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index fca93580da..89187b8e68 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -114,6 +114,7 @@ export { type RuntimeHostElectionDiagnostic, type RuntimeHostSpawnedProcess, } from './connect-or-spawn.js'; +export { waitForRuntimeHostReady } from './wait-for-ready.js'; export { createRuntimeHostCandidateLaunchBarrier, type RuntimeHostCandidateLaunchBarrier, diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index 32c7ae926d..a957ac69e0 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -25,6 +25,10 @@ import { candidateStartupFailureForExitCode, type CandidateStartupFailureReport, } from '../candidate-startup-failure.js'; +import { + RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV, + runtimeHostLaunchOwnerReleaseMessage, +} from '../candidate-launch-owner-guard.js'; import type { RuntimeHostManagedLaunchClaim } from '../operator/managed-deployment.js'; import { RUNTIME_HOST_STDERR_PIPE_ENV } from '../process-diagnostics.js'; @@ -47,6 +51,8 @@ export interface DetachedCandidateInput { executable?: string; entrypoint: string | URL; env?: NodeJS.ProcessEnv; + /** Existing authority lease inherited only by a launch-owner-supervised Candidate. */ + inheritableAuthorityLeaseFd?: number; /** Called with the candidate's exit details; the embedder owns the sink. */ readonly onExit?: (details: CandidateExitDetails) => void; } @@ -80,7 +86,7 @@ export function launchDetachedRuntimeHostCandidate( input: DetachedCandidateInput, ): DetachedCandidateLaunch { const startupAttemptId = randomUUID(); - const child = spawnCandidate(input, true, startupAttemptId); + const child = spawnCandidate(input, true, startupAttemptId, false); const exited = observeCandidateExit(child); notifyCandidateExit(child, exited, input.onExit); const startupFailure = readStartupFailure(exited, startupAttemptId); @@ -95,10 +101,12 @@ export function launchOwnedRuntimeHostCandidate(input: DetachedCandidateInput): readonly spawned: Promise; } { const startupAttemptId = randomUUID(); - const child = spawnCandidate(input, false, startupAttemptId); + const guarded = input.inheritableAuthorityLeaseFd !== undefined; + const child = spawnCandidate(input, false, startupAttemptId, guarded); const exited = observeCandidateExit(child); notifyCandidateExit(child, exited, input.onExit); const startupFailure = readStartupFailure(exited, startupAttemptId); + let released = false; return { spawned: spawnedPid(child).then(({ pid }) => ({ pid, @@ -106,7 +114,16 @@ export function launchOwnedRuntimeHostCandidate(input: DetachedCandidateInput): exited, startupFailure, releaseToEnvironment(): void { - child.unref(); + if (released) return; + released = true; + if (!guarded || !child.connected) { + child.unref(); + return; + } + child.send(runtimeHostLaunchOwnerReleaseMessage(), () => { + if (child.connected) child.disconnect(); + child.unref(); + }); }, async settle(timeoutMs: number): Promise { const result = await within(exited, timeoutMs); @@ -123,6 +140,7 @@ function spawnCandidate( input: DetachedCandidateInput, detached: boolean, startupAttemptId: string, + guarded: boolean, ): ChildProcess { const executable = input.executable ?? process.execPath; const args = [ @@ -144,16 +162,23 @@ function spawnCandidate( } // spawn() commits the side effect synchronously; spawned only reports that commit's outcome. + const inheritedLeaseFd = input.inheritableAuthorityLeaseFd; + const childLeaseFd = guarded ? 4 : undefined; const child = spawn(executable, args, { cwd: dirname(isAbsolute(executable) ? executable : process.execPath), detached, - stdio: ['ignore', 'ignore', 'pipe'], + stdio: guarded + ? ['ignore', 'ignore', 'pipe', 'ipc', inheritedLeaseFd!] + : ['ignore', 'ignore', 'pipe'], windowsHide: true, env: { ...process.env, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), ...input.env, [RUNTIME_HOST_STDERR_PIPE_ENV]: '1', + ...(childLeaseFd === undefined + ? {} + : { [RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV]: String(childLeaseFd) }), }, }); const stderr = child.stderr as (NodeJS.ReadableStream & { unref?: () => void }) | null;