From b044c7580d02b8c9dc4a55dfaf81bf330262b4c9 Mon Sep 17 00:00:00 2001 From: Wang Date: Mon, 24 Aug 2026 15:04:05 +0800 Subject: [PATCH 1/8] feat(runtime-host): apply trusted managed updates Download the exact npm package selected by update discovery, verify its registry integrity and packaged compatibility evidence, then hand it to the existing managed Host update transaction. Fence stale candidates so concurrent updates cannot overwrite a newer deployment. Generated-by: Codex --- packages/cli/README.md | 8 +- packages/cli/README.zh-CN.md | 5 +- .../runtime-host-selected-update.test.ts | 169 ++++++++++++ .../runtime-host-service-manager.test.ts | 19 ++ .../runtime-host-update-package.test.ts | 131 +++++++++ packages/cli/src/cli-core.ts | 16 +- packages/cli/src/runtime-host-cli.ts | 10 +- .../cli/src/runtime-host-update-command.ts | 149 ++++++++++ .../cli/src/runtime-host-update-discovery.ts | 117 ++++---- .../cli/src/runtime-host-update-package.ts | 260 ++++++++++++++++++ 10 files changed, 825 insertions(+), 59 deletions(-) create mode 100644 packages/cli/src/__tests__/runtime-host-selected-update.test.ts create mode 100644 packages/cli/src/__tests__/runtime-host-update-package.test.ts create mode 100644 packages/cli/src/runtime-host-update-package.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 565019460e..0e2008c1c8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -133,9 +133,11 @@ maka runtime-host service check-update --target next --json ``` The result pins the selected channel to an exact version and package integrity. It also reports -whether the package carries enough compatibility evidence for a future unattended update; this -command never installs or switches a package. An updater must still verify the downloaded archive -against that integrity and confirm the compatibility value from its extracted package manifest. +whether the package carries enough compatibility evidence for unattended use; this command never +installs or switches a package. Installation-management callers can pass the same selector to +`service update --target`. That path verifies the archive and extracted manifest before delegating +to the existing exact-package update transaction, and does not mutate a candidate that requires +manual review. ## Uninstall diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md index 4e66195a38..244830149a 100644 --- a/packages/cli/README.zh-CN.md +++ b/packages/cli/README.zh-CN.md @@ -125,8 +125,9 @@ maka runtime-host service check-update --target next --json ``` 结果会把频道固定为精确版本和 package integrity,并说明 package 是否提供足够的兼容性证据, -可供未来的无人值守更新使用;该命令不会安装或切换 package。更新程序仍须以该 integrity -校验下载的 archive,并从解压后的 package manifest 中再次确认兼容性值。 +可供无人值守流程使用;该命令不会安装或切换 package。安装管理方可以把同一 +selector 传给 `service update --target`。该路径会先校验 archive 与解包后的 manifest,再委托给 +现有的精确 package 更新事务;需要人工审查的候选不会改变当前 Host。 ## 卸载 diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts new file mode 100644 index 0000000000..7a0adf66c9 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { describe, it } from 'node:test'; +import { decodeRuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator'; +import { + runManagedRuntimeHostSelectedUpdateCli, + type RuntimeHostSelectedUpdateCliOptions, + type RuntimeHostUpdateCliOptions, +} from '../runtime-host-update-command.js'; +import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; +import type { RuntimeHostUpdateCheckResolution } from '../runtime-host-update-discovery.js'; + +const INTEGRITY = + 'sha512-jUKdo/5dbM94KXq+kOZ1d+obhDLAENfI/QWr1PnXWcdu2PqDyLklJBtiVO6HRwoL1l40z1NE9Rq+hLAxCN0Fyg=='; +const TARGET = { + serviceId: 'b'.repeat(64), + rootPath: '/srv/maka', + rootId: 'a'.repeat(64), +}; +const OPTIONS: RuntimeHostSelectedUpdateCliOptions = { + json: false, + framed: true, + clientDataRoot: '/client', + defaultRootPath: '/workspace', + selector: { kind: 'channel', channel: 'next' }, + expectedTarget: TARGET, +}; + +describe('managed Runtime Host selected update', () => { + it('parses an optional target without changing the exact-package command', () => { + assert.deepEqual( + parseRuntimeHostCommand([ + 'service', + 'update', + '--target', + 'next', + '--expected-service-id', + TARGET.serviceId, + '--expected-root-path', + TARGET.rootPath, + '--expected-root-id', + TARGET.rootId, + ]), + { + kind: 'runtime-host-service-update', + json: false, + selector: { kind: 'channel', channel: 'next' }, + expectedTarget: TARGET, + }, + ); + assert.equal( + 'selector' in + parseRuntimeHostCommand([ + 'service', + 'update', + '--expected-service-id', + TARGET.serviceId, + '--expected-root-path', + TARGET.rootPath, + '--expected-root-id', + TARGET.rootId, + ]), + false, + ); + }); + + it('hands one verified admitted package to the existing update transaction', async () => { + const resolution = updateResolution({ kind: 'unattended_update', compatibility: 7 }); + let cleanupCalls = 0; + let updateInput: RuntimeHostUpdateCliOptions | undefined; + const exitCode = await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, { + resolveCheck: async () => resolution, + acquire: async (candidate) => { + assert.deepEqual(candidate, resolution.candidate); + return { + root: '/verified/package', + cleanup: async () => { + cleanupCalls += 1; + }, + }; + }, + update: async (input) => { + updateInput = input; + return 0; + }, + }); + assert.equal(exitCode, 0); + assert.equal(updateInput?.sourcePackageRoot, '/verified/package'); + assert.equal(updateInput?.version, '2.0.0'); + assert.equal(updateInput?.expectedCurrentVersion, '1.0.0'); + assert.equal(cleanupCalls, 1); + }); + + it('keeps current and non-admitted candidates outside the mutation path', async () => { + for (const outcome of [ + { kind: 'current' as const }, + { kind: 'manual_action' as const, reason: 'compatibility_mismatch' as const }, + ]) { + let output = ''; + const exitCode = await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, { + resolveCheck: async () => updateResolution(outcome), + acquire: async () => assert.fail('package acquisition is not expected'), + update: async () => assert.fail('the update transaction is not expected'), + writeOutput: (value) => { + output += value; + }, + }); + const frame = decodeRuntimeHostServiceManagementFrame(output.trim()); + if (outcome.kind === 'current') { + assert.equal(exitCode, 0); + assert.equal( + frame?.kind === 'result' && frame.action === 'update' ? frame.update.kind : undefined, + 'already_current', + ); + } else { + assert.equal(exitCode, 1); + assert.equal(frame?.kind === 'error' ? frame.error.code : undefined, 'update_not_admitted'); + } + } + }); +}); + +function updateResolution( + outcome: RuntimeHostUpdateCheckResolution['frame']['updateCheck']['outcome'], +): RuntimeHostUpdateCheckResolution { + const candidate = { version: '2.0.0', integrity: INTEGRITY, compatibility: 7 }; + return { + candidate, + frame: { + schemaVersion: 1, + kind: 'result', + action: 'check_update', + service: { + platform: 'linux', + arch: 'x64', + osRelease: 'test', + state: 'running', + pid: 42, + lastExitCode: null, + installedVersion: outcome.kind === 'current' ? candidate.version : '1.0.0', + stateRoot: TARGET.rootPath, + projectDirectoryRoots: [], + }, + updateCheck: { + selector: OPTIONS.selector, + candidate: { version: candidate.version, integrity: candidate.integrity }, + outcome, + }, + }, + }; +} diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 56abf415c0..e1c1c06279 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -1564,6 +1564,25 @@ describe('managed Runtime Host service', () => { retirementFailure?.kind === 'error' ? retirementFailure.error.code : undefined, 'retirement_failed', ); + + statusReads = 0; + observedVersion = '3.0.0'; + operatorFailure = undefined; + output = ''; + order.length = 0; + assert.equal( + await runManagedRuntimeHostUpdateCli( + { ...options, expectedCurrentVersion: '1.0.0' }, + overrides, + ), + 1, + ); + assert.deepEqual(order, []); + const staleCandidate = decodeRuntimeHostServiceManagementFrame(output.trim()); + assert.equal( + staleCandidate?.kind === 'error' ? staleCandidate.error.code : undefined, + 'target_mismatch', + ); }); it('rejects invalid Project roots and temporary npx launch paths before deployment', async (t) => { diff --git a/packages/cli/src/__tests__/runtime-host-update-package.test.ts b/packages/cli/src/__tests__/runtime-host-update-package.test.ts new file mode 100644 index 0000000000..3c47662803 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-update-package.test.ts @@ -0,0 +1,131 @@ +/* + * 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 { createHash } from 'node:crypto'; +import { mkdir, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { + acquireRuntimeHostRegistryUpdatePackage, + RuntimeHostUpdatePackageError, +} 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('binds the official archive to its extracted release evidence', async () => { + const calls: string[][] = []; + const candidate = { version: '2.0.0', integrity: INTEGRITY, compatibility: 7 }; + const acquired = await acquireRuntimeHostRegistryUpdatePackage(candidate, async (args) => { + calls.push([...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: candidate.version, + maka: { managedRuntimeHostUpdateCompatibility: candidate.compatibility }, + }), + ), + writeFile(join(root, 'dist', 'cli.js'), ''), + writeFile(join(root, 'node_modules', '@maka', 'runtime-host', 'package.json'), '{}'), + ]); + return 0; + }); + + assert.equal((await stat(acquired.root)).isDirectory(), true); + assert.deepEqual(calls[0]?.slice(0, 2), ['pack', 'maka-agent@2.0.0']); + assert.equal(calls[0]?.includes('https://registry.npmjs.org/'), true); + assert.equal(calls[1]?.includes('--offline'), true); + assert.equal(calls[1]?.includes('--ignore-scripts'), true); + assert.equal(calls[1]?.includes('http://127.0.0.1:9/'), true); + const root = acquired.root; + await acquired.cleanup(); + await assert.rejects(stat(root), { code: 'ENOENT' }); + }); + + it('rejects archive or manifest evidence that differs from discovery', async () => { + let installed = false; + await assert.rejects( + acquireRuntimeHostRegistryUpdatePackage( + { version: '2.0.0', integrity: `sha512-${Buffer.alloc(64).toString('base64')}` }, + 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; + } + installed = true; + return 0; + }, + ), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && + error.code === 'package_integrity_mismatch', + ); + assert.equal(installed, false); + + await assert.rejects( + acquireRuntimeHostRegistryUpdatePackage( + { version: '2.0.0', integrity: INTEGRITY, compatibility: 7 }, + 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', + maka: { managedRuntimeHostUpdateCompatibility: 8 }, + }), + ), + writeFile(join(root, 'dist', 'cli.js'), ''), + writeFile(join(root, 'node_modules', '@maka', 'runtime-host', 'package.json'), '{}'), + ]); + return 0; + }, + ), + (error: unknown) => + error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package', + ); + }); +}); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 0e931db9a3..42834e336c 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -123,7 +123,7 @@ function helpText(cliCommand: string): string { ` ${cliCommand} runtime-host service status|start|stop|restart|logs|uninstall [--json]`, ` ${cliCommand} runtime-host service retire --expected-service-id --expected-root-path --expected-root-id [--allow-interrupt-active-tasks]`, ` ${cliCommand} runtime-host service check-update --target [--json]`, - ` ${cliCommand} runtime-host service update --expected-service-id --expected-root-path --expected-root-id [--allow-interrupt-active-tasks]`, + ` ${cliCommand} runtime-host service update [--target ] --expected-service-id --expected-root-path --expected-root-id [--allow-interrupt-active-tasks]`, ` ${cliCommand} runtime-host access issue --principal --grant `, ` ${cliCommand} runtime-host access issue --principal --preset `, ` ${cliCommand} runtime-host access list`, @@ -278,10 +278,22 @@ export async function runMakaCli( }); } case 'runtime-host-service-update': { - const { runManagedRuntimeHostUpdateCli } = await import('./runtime-host-update-command.js'); + const { runManagedRuntimeHostSelectedUpdateCli, runManagedRuntimeHostUpdateCli } = + await import('./runtime-host-update-command.js'); const serviceDataRoots = command.clientDataRoot ? deriveMakaDataRoots(command.clientDataRoot) : dataRoots; + if (command.selector) { + return runManagedRuntimeHostSelectedUpdateCli({ + json: command.json, + framed: command.framed ?? false, + clientDataRoot: serviceDataRoots.clientDataRoot, + defaultRootPath: serviceDataRoots.workspaceRoot, + selector: command.selector, + expectedTarget: command.expectedTarget, + ...(command.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), + }); + } return runManagedRuntimeHostUpdateCli({ json: command.json, framed: command.framed ?? false, diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index c49bd248f7..ff58ffb6ce 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -88,6 +88,7 @@ export type RuntimeHostCliCommand = framed?: true; clientDataRoot?: string; expectedTarget: RuntimeHostManagedServiceTarget; + selector?: RuntimeHostUpdateSelector; allowInterruptActiveTasks?: true; } | { @@ -291,7 +292,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { if (!isSafeAbsolutePath(value)) return error('--client-data-root must be an absolute path'); clientDataRoot = value; }, - ...(action === 'check-update' + ...(action === 'check-update' || action === 'update' ? { '--target': (value: string) => { if (updateTarget !== undefined) return error('Duplicate --target'); @@ -319,12 +320,16 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { }; } if (action === 'update') { + const selector = + updateTarget === undefined ? undefined : parseUpdateSelector(updateTarget, 'update'); + if (selector && 'kind' in selector && selector.kind === 'error') return selector; return { kind: 'runtime-host-service-update', json: options.json, ...(options.framed ? { framed: true } : {}), ...(clientDataRoot ? { clientDataRoot } : {}), expectedTarget: options.expectedTarget!, + ...(selector ? { selector } : {}), ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), }; } @@ -340,8 +345,9 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { function parseUpdateSelector( value: string | undefined, + action: 'check-update' | 'update' = 'check-update', ): RuntimeHostUpdateSelector | RuntimeHostCliError { - if (!value) return error('runtime-host service check-update requires --target'); + if (!value) return error(`runtime-host service ${action} requires --target`); if (value === 'latest' || value === 'next') { return { kind: 'channel', channel: value }; } diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 4c5997e917..532259f7f5 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -54,6 +54,15 @@ import { createPlatformRuntimeHostServiceBackend, runtimeHostServiceSummary, } from './runtime-host-service-management-command.js'; +import { + resolveManagedRuntimeHostUpdateCheck, + RuntimeHostUpdateDiscoveryError, +} from './runtime-host-update-discovery.js'; +import { + acquireRuntimeHostRegistryUpdatePackage, + RuntimeHostUpdatePackageError, +} from './runtime-host-update-package.js'; +import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; const OPERATOR_TIMEOUT_MS = 2 * 60_000; const OPERATOR_OUTPUT_MAX_BYTES = 256 * 1024; @@ -66,9 +75,15 @@ export interface RuntimeHostUpdateCliOptions { readonly sourcePackageRoot: string; readonly version: string; readonly expectedTarget: RuntimeHostManagedServiceTarget; + readonly expectedCurrentVersion?: string; readonly allowInterruptActiveTasks?: boolean; } +export interface RuntimeHostSelectedUpdateCliOptions + extends Omit { + readonly selector: RuntimeHostUpdateSelector; +} + interface RuntimeHostUpdateCliDeps { readonly manage: typeof manageRuntimeHostService; readonly replace: typeof replaceRuntimeHostManagedService; @@ -87,6 +102,14 @@ interface RuntimeHostUpdateCliDeps { readonly writeError: (value: string) => unknown; } +interface RuntimeHostSelectedUpdateCliDeps { + readonly resolveCheck: typeof resolveManagedRuntimeHostUpdateCheck; + readonly acquire: typeof acquireRuntimeHostRegistryUpdatePackage; + readonly update: typeof runManagedRuntimeHostUpdateCli; + readonly writeOutput: (value: string) => unknown; + readonly writeError: (value: string) => unknown; +} + interface RuntimeHostOperatorInvocation { readonly inheritedFds?: readonly number[]; readonly capabilityRequest?: RuntimeHostOperatorCapability; @@ -151,6 +174,16 @@ export async function runManagedRuntimeHostUpdateCli( } as const; const status = await deps.manage({ ...common, action: 'status' }, backend); const currentVersion = requireManagedVersion(status); + if ( + options.expectedCurrentVersion && + currentVersion !== options.expectedCurrentVersion && + currentVersion !== options.version + ) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The managed Runtime Host changed after its update candidate was selected', + ); + } const serviceConfig = status.service.config; if (!serviceConfig?.managedDeploymentRoot) { throw new RuntimeHostServiceManagerError( @@ -373,6 +406,102 @@ export async function runManagedRuntimeHostUpdateCli( } } +export async function runManagedRuntimeHostSelectedUpdateCli( + options: RuntimeHostSelectedUpdateCliOptions, + overrides: Partial = {}, +): Promise { + const deps: RuntimeHostSelectedUpdateCliDeps = { + resolveCheck: resolveManagedRuntimeHostUpdateCheck, + acquire: acquireRuntimeHostRegistryUpdatePackage, + update: runManagedRuntimeHostUpdateCli, + writeOutput: (value) => process.stdout.write(value), + writeError: (value) => process.stderr.write(value), + ...overrides, + }; + const emit = (frame: Exclude): void => { + if (options.framed) { + deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); + } else if (options.json) { + deps.writeOutput(`${JSON.stringify(frame)}\n`); + } else if (frame.kind === 'error') { + deps.writeError(`${frame.error.message}\n`); + } else { + if (frame.action !== 'update') { + throw new TypeError('Managed Runtime Host update returned an unrelated result'); + } + deps.writeOutput(`${humanResult(frame)}\n`); + } + }; + + try { + const resolution = await deps.resolveCheck({ + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + selector: options.selector, + expectedTarget: options.expectedTarget, + }); + const { frame, candidate } = resolution; + if (frame.updateCheck.outcome.kind === 'current') { + emit({ + schemaVersion: 1, + kind: 'result', + action: 'update', + service: frame.service, + ...operatorCapabilities(), + update: { kind: 'already_current', version: candidate.version }, + }); + return 0; + } + if (frame.updateCheck.outcome.kind === 'manual_action') { + emit({ + schemaVersion: 1, + kind: 'error', + action: 'update', + error: { + code: 'update_not_admitted', + message: manualUpdateRequiredMessage(candidate.version, frame.updateCheck.outcome.reason), + }, + }); + return 1; + } + + const acquired = await deps.acquire(candidate); + try { + const { selector: _selector, ...updateOptions } = options; + return await deps.update({ + ...updateOptions, + sourcePackageRoot: acquired.root, + version: candidate.version, + expectedCurrentVersion: frame.service.installedVersion, + }); + } finally { + await acquired.cleanup().catch(() => undefined); + } + } catch (error) { + const code = + error instanceof RuntimeHostUpdateDiscoveryError || + error instanceof RuntimeHostServiceManagerError || + error instanceof RuntimeHostUpdatePackageError + ? error.code + : 'update_resolution_failed'; + const message = error instanceof Error ? error.message : String(error); + emit({ + schemaVersion: 1, + kind: 'error', + action: 'update', + error: { + code: + truncateUtf8(code, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES) || + 'update_resolution_failed', + message: + truncateUtf8(message, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES) || + 'Unable to prepare the Runtime Host update', + }, + }); + return 1; + } +} + function progress( phase: RuntimeHostServiceUpdatePhase, currentVersion: string, @@ -541,3 +670,23 @@ function humanResult( } return `Runtime Host was updated from ${frame.update.previousVersion} to ${frame.update.targetVersion}.`; } + +function manualUpdateRequiredMessage( + targetVersion: string, + reason: + | 'target_not_newer' + | 'current_compatibility_unknown' + | 'target_compatibility_unknown' + | 'compatibility_mismatch', +): string { + if (reason === 'target_not_newer') { + return `Maka ${targetVersion} is older than the installed Runtime Host and will not be applied automatically`; + } + if (reason === 'current_compatibility_unknown') { + return 'The installed Runtime Host does not publish enough compatibility evidence for an automatic update'; + } + if (reason === 'target_compatibility_unknown') { + return `Maka ${targetVersion} does not publish enough compatibility evidence for an automatic update`; + } + return `Maka ${targetVersion} requires an explicit manual compatibility transition`; +} diff --git a/packages/cli/src/runtime-host-update-discovery.ts b/packages/cli/src/runtime-host-update-discovery.ts index f2b4be91e9..ce2b15dd6d 100644 --- a/packages/cli/src/runtime-host-update-discovery.ts +++ b/packages/cli/src/runtime-host-update-discovery.ts @@ -56,22 +56,25 @@ export interface RuntimeHostUpdateCandidate { readonly compatibility?: number; } -type RuntimeHostUpdateCheckFrame = Extract< +export type RuntimeHostUpdateCheckFrame = Extract< RuntimeHostServiceManagementFrame, { kind: 'result'; action: 'check_update' } >; type RuntimeHostUpdateCheck = RuntimeHostUpdateCheckFrame['updateCheck']; -export interface RuntimeHostUpdateCheckCliOptions { - readonly json: boolean; - readonly framed: boolean; +export interface RuntimeHostUpdateCheckOptions { readonly clientDataRoot: string; readonly defaultRootPath: string; readonly selector: RuntimeHostUpdateSelector; readonly expectedTarget?: RuntimeHostManagedServiceTarget; } -class RuntimeHostUpdateDiscoveryError extends Error { +export interface RuntimeHostUpdateCheckCliOptions extends RuntimeHostUpdateCheckOptions { + readonly json: boolean; + readonly framed: boolean; +} + +export class RuntimeHostUpdateDiscoveryError extends Error { constructor( readonly code: 'target_unavailable' | 'registry_unavailable' | 'invalid_registry_metadata', message: string, @@ -86,51 +89,7 @@ export async function runManagedRuntimeHostUpdateCheckCli( options: RuntimeHostUpdateCheckCliOptions, ): Promise { try { - const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); - const backend = createPlatformRuntimeHostServiceBackend(serviceId); - const status = await manageRuntimeHostService( - { - action: 'status', - clientDataRoot: options.clientDataRoot, - defaultRootPath: options.defaultRootPath, - nodePath: process.execPath, - cliPath: process.argv[1] ?? '', - ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), - }, - backend, - ); - const currentVersion = status.service.installedVersion; - const config = status.service.config; - const service = runtimeHostServiceSummary(status); - const serviceState = service.state; - if ( - !status.service.installed || - serviceState === 'not_installed' || - !currentVersion || - !config?.managedDeploymentRoot - ) { - throw new RuntimeHostServiceManagerError( - 'not_installed', - 'A Maka-managed Runtime Host service is required to check for updates', - ); - } - await backend.verifyDeployment(config); - const [candidate, currentCompatibility] = await Promise.all([ - resolveRuntimeHostRegistryUpdateCandidate(options.selector), - readPackageCompatibility(config.launch.cliPath, currentVersion), - ]); - const assessment = assessRuntimeHostUpdate(currentVersion, currentCompatibility, candidate); - const frame: RuntimeHostUpdateCheckFrame = { - schemaVersion: 1, - kind: 'result', - action: 'check_update', - service: { ...service, state: serviceState, installedVersion: currentVersion }, - updateCheck: { - selector: options.selector, - candidate: { version: candidate.version, integrity: candidate.integrity }, - outcome: assessment, - }, - }; + const { frame } = await resolveManagedRuntimeHostUpdateCheck(options); writeSuccess(frame, options); return 0; } catch (error) { @@ -145,6 +104,64 @@ export async function runManagedRuntimeHostUpdateCheckCli( } } +export interface RuntimeHostUpdateCheckResolution { + readonly frame: RuntimeHostUpdateCheckFrame; + readonly candidate: RuntimeHostUpdateCandidate; +} + +export async function resolveManagedRuntimeHostUpdateCheck( + options: RuntimeHostUpdateCheckOptions, +): Promise { + const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); + const backend = createPlatformRuntimeHostServiceBackend(serviceId); + const status = await manageRuntimeHostService( + { + action: 'status', + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + nodePath: process.execPath, + cliPath: process.argv[1] ?? '', + ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), + }, + backend, + ); + const currentVersion = status.service.installedVersion; + const config = status.service.config; + const service = runtimeHostServiceSummary(status); + const serviceState = service.state; + if ( + !status.service.installed || + serviceState === 'not_installed' || + !currentVersion || + !config?.managedDeploymentRoot + ) { + throw new RuntimeHostServiceManagerError( + 'not_installed', + 'A Maka-managed Runtime Host service is required to check for updates', + ); + } + await backend.verifyDeployment(config); + const [candidate, currentCompatibility] = await Promise.all([ + resolveRuntimeHostRegistryUpdateCandidate(options.selector), + readPackageCompatibility(config.launch.cliPath, currentVersion), + ]); + const assessment = assessRuntimeHostUpdate(currentVersion, currentCompatibility, candidate); + return { + candidate, + frame: { + schemaVersion: 1, + kind: 'result', + action: 'check_update', + service: { ...service, state: serviceState, installedVersion: currentVersion }, + updateCheck: { + selector: options.selector, + candidate: { version: candidate.version, integrity: candidate.integrity }, + outcome: assessment, + }, + }, + }; +} + export function assessRuntimeHostUpdate( currentVersion: string, currentCompatibility: number | undefined, diff --git a/packages/cli/src/runtime-host-update-package.ts b/packages/cli/src/runtime-host-update-package.ts new file mode 100644 index 0000000000..eb2354ff22 --- /dev/null +++ b/packages/cli/src/runtime-host-update-package.ts @@ -0,0 +1,260 @@ +/* + * 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 } from 'node:crypto'; +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 { isProductReleaseVersion, isSha512PackageIntegrity } from '@maka/runtime-host/operator'; +import type { RuntimeHostUpdateCandidate } from './runtime-host-update-discovery.js'; + +const PACKAGE_NAME = 'maka-agent'; +const NPM_REGISTRY = 'https://registry.npmjs.org/'; +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; +const MANIFEST_MAX_BYTES = 64 * 1024; + +export class RuntimeHostUpdatePackageError extends Error { + constructor( + readonly code: 'package_download_failed' | 'package_integrity_mismatch' | 'invalid_package', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RuntimeHostUpdatePackageError'; + } +} + +export interface RuntimeHostAcquiredUpdatePackage { + readonly root: string; + cleanup(): Promise; +} + +type RunNpm = (args: readonly string[], cwd: string) => Promise; + +export async function acquireRuntimeHostRegistryUpdatePackage( + candidate: RuntimeHostUpdateCandidate, + runNpm: RunNpm = runNpmCommand, +): Promise { + if ( + !isProductReleaseVersion(candidate.version) || + !isSha512PackageIntegrity(candidate.integrity) || + (candidate.compatibility !== undefined && + (!Number.isInteger(candidate.compatibility) || candidate.compatibility <= 0)) + ) { + throw new RuntimeHostUpdatePackageError( + 'invalid_package', + 'The selected Runtime Host update candidate is invalid', + ); + } + + const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-runtime-host-update-')); + try { + const downloadRoot = join(temporaryRoot, 'download'); + const installRoot = join(temporaryRoot, 'install'); + const emptyCache = join(temporaryRoot, 'empty-cache'); + await mkdir(downloadRoot, { mode: 0o700 }); + const packed = await runNpm( + [ + 'pack', + `${PACKAGE_NAME}@${candidate.version}`, + '--pack-destination', + downloadRoot, + '--registry', + NPM_REGISTRY, + '--ignore-scripts', + ], + temporaryRoot, + ); + if (packed !== 0) { + throw new RuntimeHostUpdatePackageError( + 'package_download_failed', + `Unable to download Maka ${candidate.version} from the official npm registry`, + ); + } + + 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`, + ); + } + + const packageRoot = await validateExtractedPackage(installRoot, candidate); + return { + root: packageRoot, + cleanup: () => rm(temporaryRoot, { recursive: true, force: true }), + }; + } catch (error) { + await rm(temporaryRoot, { recursive: true, force: true }).catch(() => undefined); + if (error instanceof RuntimeHostUpdatePackageError) throw error; + throw new RuntimeHostUpdatePackageError( + 'package_download_failed', + `Unable to prepare Maka ${candidate.version} for a managed Runtime Host update`, + { cause: error }, + ); + } +} + +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')) { + throw new RuntimeHostUpdatePackageError( + 'package_download_failed', + 'The npm registry did not return one Maka package archive', + ); + } + const archive = join(downloadRoot, entries[0].name); + 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 downloaded Maka package archive is invalid', + ); + } + return archive; +} + +async function packageIntegrity(path: string): Promise { + const hash = createHash('sha512'); + for await (const chunk of createReadStream(path)) hash.update(chunk as Buffer); + return `sha512-${hash.digest('base64')}`; +} + +async function validateExtractedPackage( + installRoot: string, + candidate: RuntimeHostUpdateCandidate, +): Promise { + try { + const packageRoot = await realpath(join(installRoot, 'node_modules', PACKAGE_NAME)); + const manifestPath = join(packageRoot, 'package.json'); + const [manifestMetadata, cli, runtimeHost] = await Promise.all([ + stat(manifestPath), + stat(join(packageRoot, 'dist', 'cli.js')), + stat(join(packageRoot, 'node_modules', '@maka', 'runtime-host', 'package.json')), + ]); + if ( + !manifestMetadata.isFile() || + manifestMetadata.size > MANIFEST_MAX_BYTES || + !cli.isFile() || + !runtimeHost.isFile() + ) { + throw new RuntimeHostUpdatePackageError( + 'invalid_package', + 'The downloaded Maka package is not a self-contained release', + ); + } + const manifest: unknown = JSON.parse(await readFile(manifestPath, 'utf8')); + const compatibility = + isRecord(manifest) && isRecord(manifest.maka) + ? positiveInteger(manifest.maka.managedRuntimeHostUpdateCompatibility) + : undefined; + if ( + !isRecord(manifest) || + manifest.name !== PACKAGE_NAME || + manifest.version !== candidate.version || + compatibility !== candidate.compatibility + ) { + throw new RuntimeHostUpdatePackageError( + 'invalid_package', + 'The downloaded Maka package does not match its registry metadata', + ); + } + return packageRoot; + } catch (error) { + if (error instanceof RuntimeHostUpdatePackageError) throw error; + throw new RuntimeHostUpdatePackageError( + 'invalid_package', + 'The downloaded Maka package manifest is invalid', + { cause: error }, + ); + } +} + +function runNpmCommand(args: readonly string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn('npm', [...args], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: NPM_TIMEOUT_MS, + killSignal: 'SIGKILL', + }); + let outputBytes = 0; + let outputExceeded = false; + const observe = (chunk: Buffer) => { + outputBytes += chunk.byteLength; + if (outputBytes <= NPM_OUTPUT_MAX_BYTES) return; + outputExceeded = true; + child.kill('SIGKILL'); + }; + child.stdout.on('data', observe); + child.stderr.on('data', observe); + child.once('error', reject); + child.once('close', (code) => { + if (outputExceeded) { + reject(new Error('npm returned too much output while preparing the update package')); + } else { + resolve(code ?? 1); + } + }); + }); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} From 048e9b4571efd0568541e6ab2b26663eb3b290af Mon Sep 17 00:00:00 2001 From: Wang Date: Mon, 24 Aug 2026 16:27:54 +0800 Subject: [PATCH 2/8] fix(runtime-host): preserve verified update identity Bind registry artifacts to their verified integrity across deployment and route current selections through the existing readiness and repair transaction. Scope temporary acquisition to the update operation and prune inactive managed packages only after a healthy cutover. Generated-by: Codex --- .../runtime-host-selected-update.test.ts | 125 ++++++++-------- .../runtime-host-service-manager.test.ts | 5 +- .../src/__tests__/runtime-host-setup.test.ts | 42 +++++- .../runtime-host-update-package.test.ts | 92 +++++++----- .../src/runtime-host-managed-deployment.ts | 60 +++++--- .../cli/src/runtime-host-update-command.ts | 64 ++++----- .../cli/src/runtime-host-update-discovery.ts | 64 ++++++--- .../cli/src/runtime-host-update-package.ts | 133 +++++++++--------- 8 files changed, 353 insertions(+), 232 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index 7a0adf66c9..5f16cd51ca 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -26,7 +26,7 @@ import { type RuntimeHostUpdateCliOptions, } from '../runtime-host-update-command.js'; import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; -import type { RuntimeHostUpdateCheckResolution } from '../runtime-host-update-discovery.js'; +import type { RuntimeHostUpdateSelection } from '../runtime-host-update-discovery.js'; const INTEGRITY = 'sha512-jUKdo/5dbM94KXq+kOZ1d+obhDLAENfI/QWr1PnXWcdu2PqDyLklJBtiVO6HRwoL1l40z1NE9Rq+hLAxCN0Fyg=='; @@ -83,19 +83,16 @@ describe('managed Runtime Host selected update', () => { }); it('hands one verified admitted package to the existing update transaction', async () => { - const resolution = updateResolution({ kind: 'unattended_update', compatibility: 7 }); - let cleanupCalls = 0; + const selection = updateSelection({ + kind: 'unattended_update', + compatibility: 7, + }); let updateInput: RuntimeHostUpdateCliOptions | undefined; const exitCode = await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, { - resolveCheck: async () => resolution, - acquire: async (candidate) => { - assert.deepEqual(candidate, resolution.candidate); - return { - root: '/verified/package', - cleanup: async () => { - cleanupCalls += 1; - }, - }; + resolveSelection: async () => selection, + withPackage: async (candidate, use) => { + assert.deepEqual(candidate, selection.candidate); + return use('/verified/package'); }, update: async (input) => { updateInput = input; @@ -106,64 +103,68 @@ describe('managed Runtime Host selected update', () => { assert.equal(updateInput?.sourcePackageRoot, '/verified/package'); assert.equal(updateInput?.version, '2.0.0'); assert.equal(updateInput?.expectedCurrentVersion, '1.0.0'); - assert.equal(cleanupCalls, 1); + assert.equal(updateInput?.packageIntegrity, INTEGRITY); }); - it('keeps current and non-admitted candidates outside the mutation path', async () => { - for (const outcome of [ - { kind: 'current' as const }, - { kind: 'manual_action' as const, reason: 'compatibility_mismatch' as const }, - ]) { - let output = ''; - const exitCode = await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, { - resolveCheck: async () => updateResolution(outcome), - acquire: async () => assert.fail('package acquisition is not expected'), - update: async () => assert.fail('the update transaction is not expected'), - writeOutput: (value) => { - output += value; + it('lets the exact transaction decide whether a current candidate needs repair', async () => { + const selection = updateSelection({ kind: 'current' }); + let updates = 0; + assert.equal( + await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, { + resolveSelection: async () => selection, + withPackage: async (_candidate, use) => use('/verified/package'), + update: async () => { + updates += 1; + return 0; }, - }); - const frame = decodeRuntimeHostServiceManagementFrame(output.trim()); - if (outcome.kind === 'current') { - assert.equal(exitCode, 0); - assert.equal( - frame?.kind === 'result' && frame.action === 'update' ? frame.update.kind : undefined, - 'already_current', - ); - } else { - assert.equal(exitCode, 1); - assert.equal(frame?.kind === 'error' ? frame.error.code : undefined, 'update_not_admitted'); - } - } + }), + 0, + ); + assert.equal(updates, 1); + }); + + it('keeps non-admitted candidates outside package acquisition and mutation', async () => { + let output = ''; + const exitCode = await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, { + resolveSelection: async () => + updateSelection({ + kind: 'manual_action', + reason: 'compatibility_mismatch', + }), + withPackage: async () => assert.fail('package acquisition is not expected'), + update: async () => assert.fail('the update transaction is not expected'), + writeOutput: (value) => { + output += value; + }, + }); + const frame = decodeRuntimeHostServiceManagementFrame(output.trim()); + assert.equal(exitCode, 1); + assert.equal(frame?.kind === 'error' ? frame.error.code : undefined, 'update_not_admitted'); }); }); -function updateResolution( - outcome: RuntimeHostUpdateCheckResolution['frame']['updateCheck']['outcome'], -): RuntimeHostUpdateCheckResolution { - const candidate = { version: '2.0.0', integrity: INTEGRITY, compatibility: 7 }; +function updateSelection( + outcome: RuntimeHostUpdateSelection['outcome'], +): RuntimeHostUpdateSelection { + const candidate = { + version: '2.0.0', + integrity: INTEGRITY, + compatibility: 7, + }; return { + selector: OPTIONS.selector, candidate, - frame: { - schemaVersion: 1, - kind: 'result', - action: 'check_update', - service: { - platform: 'linux', - arch: 'x64', - osRelease: 'test', - state: 'running', - pid: 42, - lastExitCode: null, - installedVersion: outcome.kind === 'current' ? candidate.version : '1.0.0', - stateRoot: TARGET.rootPath, - projectDirectoryRoots: [], - }, - updateCheck: { - selector: OPTIONS.selector, - candidate: { version: candidate.version, integrity: candidate.integrity }, - outcome, - }, + outcome, + service: { + platform: 'linux', + arch: 'x64', + osRelease: 'test', + state: 'running', + pid: 42, + lastExitCode: null, + installedVersion: outcome.kind === 'current' ? candidate.version : '1.0.0', + stateRoot: TARGET.rootPath, + projectDirectoryRoots: [], }, }; } diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index e1c1c06279..21809769a9 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -1464,6 +1464,9 @@ describe('managed Runtime Host service', () => { readyChecks += 1; if (readyFailure) throw new Error('Host is active but not ready'); }, + prunePackages: async () => { + order.push('cleanup'); + }, manage: async (input: Parameters[0]) => { assert.equal(input.action, 'status'); statusReads += 1; @@ -1517,7 +1520,7 @@ describe('managed Runtime Host service', () => { output = ''; assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0); assert.equal(readyChecks, 1); - assert.deepEqual(order, []); + assert.deepEqual(order, ['cleanup']); order.length = 0; statusReads = 0; diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 43d34f8b34..6be34d8a1b 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -30,7 +30,7 @@ import { writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { test } from 'node:test'; import { promisify } from 'node:util'; import { @@ -52,6 +52,7 @@ import { } from '../runtime-host-service-manager.js'; const execFile = promisify(execFileCallback); +const PACKAGE_INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString('base64')}`; test('managed setup converges on one exact package and verified Client pairing', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-')); @@ -281,6 +282,45 @@ test('managed setup replaces one exact development package with another', async assert.deepEqual(await readdir(join(previousDeployment.root, 'versions')), [nextVersion]); }); +test('registry package identity does not reuse same-version local content', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-registry-package-')); + t.after(() => rm(base, { recursive: true, force: true })); + const version = '0.2.0'; + const localPackage = await createReleasePackage(join(base, 'local'), version); + const registryPackage = await createReleasePackage(join(base, 'registry'), version); + await writeFile(join(localPackage, 'dist', 'cli.js'), 'local package\n'); + await writeFile(join(registryPackage, 'dist', 'cli.js'), 'registry package\n'); + const clientDataRoot = join(base, 'config', 'Maka'); + const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); + const pathOptions = { + env: { XDG_DATA_HOME: join(base, 'data') }, + homeDir: join(base, 'home'), + platform: 'linux' as const, + }; + const local = await prepareRuntimeHostManagedPackageDeployment( + { serviceId, clientDataRoot, sourcePackageRoot: localPackage, version }, + pathOptions, + ); + const registry = await prepareRuntimeHostManagedPackageDeployment( + { + serviceId, + clientDataRoot, + sourcePackageRoot: registryPackage, + version, + packageIntegrity: PACKAGE_INTEGRITY, + }, + pathOptions, + ); + + assert.notEqual(local.cliPath, registry.cliPath); + assert.match(registry.cliPath, /\/versions\/registry-[a-f0-9]{64}\/dist\/cli\.js$/u); + assert.equal(await readFile(registry.cliPath, 'utf8'), 'registry package\n'); + await registry.cleanup(); + assert.deepEqual(await readdir(dirname(dirname(dirname(registry.cliPath)))), [ + basename(dirname(dirname(registry.cliPath))), + ]); +}); + test('managed setup removes a newly copied package when service installation fails', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-failure-')); t.after(() => rm(base, { recursive: true, force: true })); 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 3c47662803..d29a67badd 100644 --- a/packages/cli/src/__tests__/runtime-host-update-package.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-package.test.ts @@ -23,8 +23,8 @@ import { mkdir, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { describe, it } from 'node:test'; import { - acquireRuntimeHostRegistryUpdatePackage, RuntimeHostUpdatePackageError, + withRuntimeHostRegistryUpdatePackage, } from '../runtime-host-update-package.js'; const ARCHIVE = Buffer.from('verified release archive'); @@ -33,51 +33,68 @@ const INTEGRITY = `sha512-${createHash('sha512').update(ARCHIVE).digest('base64' describe('managed Runtime Host update package acquisition', () => { it('binds the official archive to its extracted release evidence', async () => { const calls: string[][] = []; - const candidate = { version: '2.0.0', integrity: INTEGRITY, compatibility: 7 }; - const acquired = await acquireRuntimeHostRegistryUpdatePackage(candidate, async (args) => { - calls.push([...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: candidate.version, - maka: { managedRuntimeHostUpdateCompatibility: candidate.compatibility }, + const candidate = { + version: '2.0.0', + integrity: INTEGRITY, + compatibility: 7, + }; + let acquiredRoot = ''; + await withRuntimeHostRegistryUpdatePackage( + candidate, + async (root) => { + acquiredRoot = root; + assert.equal((await stat(root)).isDirectory(), true); + }, + async (args) => { + calls.push([...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, }), - ), - writeFile(join(root, 'dist', 'cli.js'), ''), - writeFile(join(root, 'node_modules', '@maka', 'runtime-host', 'package.json'), '{}'), - ]); - return 0; - }); + ]); + await Promise.all([ + writeFile( + join(root, 'package.json'), + JSON.stringify({ + name: 'maka-agent', + version: candidate.version, + maka: { + managedRuntimeHostUpdateCompatibility: candidate.compatibility, + }, + }), + ), + writeFile(join(root, 'dist', 'cli.js'), ''), + writeFile(join(root, 'node_modules', '@maka', 'runtime-host', 'package.json'), '{}'), + ]); + return 0; + }, + ); - assert.equal((await stat(acquired.root)).isDirectory(), true); assert.deepEqual(calls[0]?.slice(0, 2), ['pack', 'maka-agent@2.0.0']); assert.equal(calls[0]?.includes('https://registry.npmjs.org/'), true); assert.equal(calls[1]?.includes('--offline'), true); assert.equal(calls[1]?.includes('--ignore-scripts'), true); assert.equal(calls[1]?.includes('http://127.0.0.1:9/'), true); - const root = acquired.root; - await acquired.cleanup(); - await assert.rejects(stat(root), { code: 'ENOENT' }); + await assert.rejects(stat(acquiredRoot), { code: 'ENOENT' }); }); it('rejects archive or manifest evidence that differs from discovery', async () => { let installed = false; await assert.rejects( - acquireRuntimeHostRegistryUpdatePackage( - { version: '2.0.0', integrity: `sha512-${Buffer.alloc(64).toString('base64')}` }, + withRuntimeHostRegistryUpdatePackage( + { + version: '2.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + }, + async () => assert.fail('invalid integrity must not expose a package'), async (args) => { if (args[0] === 'pack') { const destination = args[args.indexOf('--pack-destination') + 1]!; @@ -95,8 +112,9 @@ describe('managed Runtime Host update package acquisition', () => { assert.equal(installed, false); await assert.rejects( - acquireRuntimeHostRegistryUpdatePackage( + withRuntimeHostRegistryUpdatePackage( { version: '2.0.0', integrity: INTEGRITY, compatibility: 7 }, + async () => assert.fail('invalid manifest must not expose a package'), async (args) => { if (args[0] === 'pack') { const destination = args[args.indexOf('--pack-destination') + 1]!; @@ -107,7 +125,9 @@ describe('managed Runtime Host update package acquisition', () => { 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 }), + mkdir(join(root, 'node_modules', '@maka', 'runtime-host'), { + recursive: true, + }), ]); await Promise.all([ writeFile( diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index f5dccc1ad0..afa58b4cb6 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -17,7 +17,7 @@ * under the License. */ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { cp, lstat, @@ -32,6 +32,7 @@ import { } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { isSha512PackageIntegrity } from '@maka/runtime-host/operator'; const PACKAGE_NAME = 'maka-agent'; @@ -66,6 +67,7 @@ export async function prepareRuntimeHostManagedPackageDeployment( readonly clientDataRoot: string; readonly sourcePackageRoot: string; readonly version: string; + readonly packageIntegrity?: string; }, options: RuntimeHostManagedDeploymentPathOptions = {}, ): Promise { @@ -75,7 +77,10 @@ export async function prepareRuntimeHostManagedPackageDeployment( await mkdir(join(requestedDeploymentRoot, 'versions'), { recursive: true, mode: 0o700 }); const deploymentRoot = await realpath(requestedDeploymentRoot); const versionsRoot = join(deploymentRoot, 'versions'); - const packageRoot = join(versionsRoot, input.version); + const packageDirectory = input.packageIntegrity + ? registryPackageDirectory(input.packageIntegrity) + : input.version; + const packageRoot = join(versionsRoot, packageDirectory); const cliPath = join(packageRoot, 'dist', 'cli.js'); const clientDataRoot = resolve(input.clientDataRoot); if (await pathExists(packageRoot)) { @@ -83,8 +88,8 @@ export async function prepareRuntimeHostManagedPackageDeployment( return deployment(input.version, deploymentRoot, packageRoot, cliPath, clientDataRoot, false); } - await removeAbandonedStagingPackages(versionsRoot, input.version); - const stagingRoot = join(versionsRoot, `.${input.version}.${randomUUID()}.tmp`); + await removeAbandonedStagingPackages(versionsRoot, packageDirectory); + const stagingRoot = join(versionsRoot, `.${packageDirectory}.${randomUUID()}.tmp`); try { await cp(sourcePackageRoot, stagingRoot, { recursive: true, @@ -217,6 +222,28 @@ export async function removeRuntimeHostManagedDeployment( await rm(requestedRoot, { recursive: true, force: true }); } +export async function pruneRuntimeHostManagedDeploymentPackages( + deploymentRoot: string, + retainedCliPath: string, +): Promise { + const versionsRoot = join(await realpath(resolve(deploymentRoot)), 'versions'); + const packageRoot = dirname(dirname(await realpath(retainedCliPath))); + const pathFromVersions = relative(versionsRoot, packageRoot); + if ( + pathFromVersions === '' || + pathFromVersions === '..' || + pathFromVersions.startsWith(`..${sep}`) || + isAbsolute(pathFromVersions) || + pathFromVersions.includes(sep) + ) { + throw new RuntimeHostManagedDeploymentError( + 'deployment_failed', + 'Refusing to prune packages for an invalid managed Runtime Host CLI path', + ); + } + await pruneInactivePackages(versionsRoot, pathFromVersions); +} + async function validatePackage(path: string, version: string): Promise { let packageRoot: string; let manifest: unknown; @@ -269,7 +296,7 @@ function deployment( cliPath, operatorPath, activate: () => writeOperatorLauncher(operatorPath, process.execPath, cliPath, clientDataRoot), - cleanup: () => pruneInactiveDevelopmentPackages(dirname(packageRoot), version), + cleanup: () => pruneInactivePackages(dirname(packageRoot), basename(packageRoot)), rollback: () => created ? rm(packageRoot, { recursive: true, force: true }) : Promise.resolve(), }; @@ -319,23 +346,24 @@ function quotePosix(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } -async function pruneInactiveDevelopmentPackages( - versionsRoot: string, - retainedVersion: string, -): Promise { - if (!isRuntimeHostDevelopmentPackageVersion(retainedVersion)) return; +async function pruneInactivePackages(versionsRoot: string, retainedPackage: string): Promise { await Promise.all( (await readdir(versionsRoot, { withFileTypes: true })) - .filter( - (entry) => - entry.isDirectory() && - entry.name !== retainedVersion && - isRuntimeHostDevelopmentPackageVersion(entry.name), - ) + .filter((entry) => entry.name !== retainedPackage) .map((entry) => rm(join(versionsRoot, entry.name), { recursive: true, force: true })), ); } +function registryPackageDirectory(integrity: string): string { + if (!isSha512PackageIntegrity(integrity)) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_package', + 'The managed Runtime Host package integrity is invalid', + ); + } + return `registry-${createHash('sha256').update(integrity).digest('hex')}`; +} + function assertVersion(version: string): void { if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/u.test(version)) { throw new RuntimeHostManagedDeploymentError( diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 532259f7f5..90e889031e 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -33,6 +33,7 @@ import { type RuntimeHostServiceUpdatePhase, } from '@maka/runtime-host/operator'; import { + pruneRuntimeHostManagedDeploymentPackages, prepareRuntimeHostManagedPackageDeployment, RuntimeHostManagedDeploymentError, type RuntimeHostManagedPackageDeployment, @@ -55,12 +56,12 @@ import { runtimeHostServiceSummary, } from './runtime-host-service-management-command.js'; import { - resolveManagedRuntimeHostUpdateCheck, + resolveManagedRuntimeHostUpdateSelection, RuntimeHostUpdateDiscoveryError, } from './runtime-host-update-discovery.js'; import { - acquireRuntimeHostRegistryUpdatePackage, RuntimeHostUpdatePackageError, + withRuntimeHostRegistryUpdatePackage, } from './runtime-host-update-package.js'; import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; @@ -76,6 +77,7 @@ export interface RuntimeHostUpdateCliOptions { readonly version: string; readonly expectedTarget: RuntimeHostManagedServiceTarget; readonly expectedCurrentVersion?: string; + readonly packageIntegrity?: string; readonly allowInterruptActiveTasks?: boolean; } @@ -93,6 +95,7 @@ interface RuntimeHostUpdateCliDeps { readonly withLegacyOperatorLeases: typeof withRuntimeHostManagedServiceLegacyOperatorLeases; readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend; readonly verifyReady: typeof verifyRuntimeHostManagedServiceReady; + readonly prunePackages: typeof pruneRuntimeHostManagedDeploymentPackages; readonly runOperator: ( operatorPath: string, args: readonly string[], @@ -103,8 +106,8 @@ interface RuntimeHostUpdateCliDeps { } interface RuntimeHostSelectedUpdateCliDeps { - readonly resolveCheck: typeof resolveManagedRuntimeHostUpdateCheck; - readonly acquire: typeof acquireRuntimeHostRegistryUpdatePackage; + readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection; + readonly withPackage: typeof withRuntimeHostRegistryUpdatePackage; readonly update: typeof runManagedRuntimeHostUpdateCli; readonly writeOutput: (value: string) => unknown; readonly writeError: (value: string) => unknown; @@ -128,6 +131,7 @@ export async function runManagedRuntimeHostUpdateCli( withLegacyOperatorLeases: withRuntimeHostManagedServiceLegacyOperatorLeases, createBackend: createPlatformRuntimeHostServiceBackend, verifyReady: verifyRuntimeHostManagedServiceReady, + prunePackages: pruneRuntimeHostManagedDeploymentPackages, runOperator: runManagedRuntimeHostOperator, writeOutput: (value) => process.stdout.write(value), writeError: (value) => process.stderr.write(value), @@ -196,6 +200,14 @@ export async function runManagedRuntimeHostUpdateCli( if (currentVersion === options.version && status.service.active) { try { await deps.verifyReady(serviceConfig, backend); + } catch { + activeTargetNeedsRepair = true; + } + if (!activeTargetNeedsRepair) { + await deps.prunePackages( + serviceConfig.managedDeploymentRoot, + serviceConfig.launch.cliPath, + ); emit({ schemaVersion: 1, kind: 'result', @@ -205,8 +217,6 @@ export async function runManagedRuntimeHostUpdateCli( update: { kind: 'already_current', version: options.version }, }); return 0; - } catch { - activeTargetNeedsRepair = true; } } @@ -228,6 +238,7 @@ export async function runManagedRuntimeHostUpdateCli( clientDataRoot: options.clientDataRoot, sourcePackageRoot: options.sourcePackageRoot, version: options.version, + ...(options.packageIntegrity ? { packageIntegrity: options.packageIntegrity } : {}), }), ); if (deployment.root !== serviceConfig.managedDeploymentRoot) { @@ -349,7 +360,7 @@ export async function runManagedRuntimeHostUpdateCli( 'The replacement Runtime Host did not report the selected package version as ready', ); } - await targetDeployment.cleanup().catch(() => undefined); + await targetDeployment.cleanup(); return { schemaVersion: 1, action: 'status', service: updatedService } as const; }); emit({ @@ -411,8 +422,8 @@ export async function runManagedRuntimeHostSelectedUpdateCli( overrides: Partial = {}, ): Promise { const deps: RuntimeHostSelectedUpdateCliDeps = { - resolveCheck: resolveManagedRuntimeHostUpdateCheck, - acquire: acquireRuntimeHostRegistryUpdatePackage, + resolveSelection: resolveManagedRuntimeHostUpdateSelection, + withPackage: withRuntimeHostRegistryUpdatePackage, update: runManagedRuntimeHostUpdateCli, writeOutput: (value) => process.stdout.write(value), writeError: (value) => process.stderr.write(value), @@ -434,49 +445,38 @@ export async function runManagedRuntimeHostSelectedUpdateCli( }; try { - const resolution = await deps.resolveCheck({ + const selection = await deps.resolveSelection({ clientDataRoot: options.clientDataRoot, defaultRootPath: options.defaultRootPath, selector: options.selector, expectedTarget: options.expectedTarget, }); - const { frame, candidate } = resolution; - if (frame.updateCheck.outcome.kind === 'current') { - emit({ - schemaVersion: 1, - kind: 'result', - action: 'update', - service: frame.service, - ...operatorCapabilities(), - update: { kind: 'already_current', version: candidate.version }, - }); - return 0; - } - if (frame.updateCheck.outcome.kind === 'manual_action') { + if (selection.outcome.kind === 'manual_action') { emit({ schemaVersion: 1, kind: 'error', action: 'update', error: { code: 'update_not_admitted', - message: manualUpdateRequiredMessage(candidate.version, frame.updateCheck.outcome.reason), + message: manualUpdateRequiredMessage( + selection.candidate.version, + selection.outcome.reason, + ), }, }); return 1; } - const acquired = await deps.acquire(candidate); - try { + return await deps.withPackage(selection.candidate, async (packageRoot) => { const { selector: _selector, ...updateOptions } = options; return await deps.update({ ...updateOptions, - sourcePackageRoot: acquired.root, - version: candidate.version, - expectedCurrentVersion: frame.service.installedVersion, + sourcePackageRoot: packageRoot, + version: selection.candidate.version, + expectedCurrentVersion: selection.service.installedVersion, + packageIntegrity: selection.candidate.integrity, }); - } finally { - await acquired.cleanup().catch(() => undefined); - } + }); } catch (error) { const code = error instanceof RuntimeHostUpdateDiscoveryError || diff --git a/packages/cli/src/runtime-host-update-discovery.ts b/packages/cli/src/runtime-host-update-discovery.ts index ce2b15dd6d..1ed6c1b332 100644 --- a/packages/cli/src/runtime-host-update-discovery.ts +++ b/packages/cli/src/runtime-host-update-discovery.ts @@ -89,7 +89,7 @@ export async function runManagedRuntimeHostUpdateCheckCli( options: RuntimeHostUpdateCheckCliOptions, ): Promise { try { - const { frame } = await resolveManagedRuntimeHostUpdateCheck(options); + const frame = await resolveManagedRuntimeHostUpdateCheck(options); writeSuccess(frame, options); return 0; } catch (error) { @@ -104,14 +104,29 @@ export async function runManagedRuntimeHostUpdateCheckCli( } } -export interface RuntimeHostUpdateCheckResolution { - readonly frame: RuntimeHostUpdateCheckFrame; +export interface RuntimeHostUpdateSelection { + readonly service: RuntimeHostUpdateCheckFrame['service']; + readonly selector: RuntimeHostUpdateSelector; readonly candidate: RuntimeHostUpdateCandidate; + readonly outcome: RuntimeHostUpdateCheck['outcome']; +} + +export function resolveManagedRuntimeHostUpdateSelection( + options: RuntimeHostUpdateCheckOptions, +): Promise { + return resolveManagedRuntimeHostUpdate(options, false); +} + +async function resolveManagedRuntimeHostUpdateCheck( + options: RuntimeHostUpdateCheckOptions, +): Promise { + return updateCheckFrame(await resolveManagedRuntimeHostUpdate(options, true)); } -export async function resolveManagedRuntimeHostUpdateCheck( +async function resolveManagedRuntimeHostUpdate( options: RuntimeHostUpdateCheckOptions, -): Promise { + verifyDeployment: boolean, +): Promise { const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); const backend = createPlatformRuntimeHostServiceBackend(serviceId); const status = await manageRuntimeHostService( @@ -140,24 +155,37 @@ export async function resolveManagedRuntimeHostUpdateCheck( 'A Maka-managed Runtime Host service is required to check for updates', ); } - await backend.verifyDeployment(config); + if (verifyDeployment) await backend.verifyDeployment(config); const [candidate, currentCompatibility] = await Promise.all([ resolveRuntimeHostRegistryUpdateCandidate(options.selector), readPackageCompatibility(config.launch.cliPath, currentVersion), ]); const assessment = assessRuntimeHostUpdate(currentVersion, currentCompatibility, candidate); return { + service: { + ...service, + state: serviceState, + installedVersion: currentVersion, + }, + selector: options.selector, candidate, - frame: { - schemaVersion: 1, - kind: 'result', - action: 'check_update', - service: { ...service, state: serviceState, installedVersion: currentVersion }, - updateCheck: { - selector: options.selector, - candidate: { version: candidate.version, integrity: candidate.integrity }, - outcome: assessment, + outcome: assessment, + }; +} + +function updateCheckFrame(selection: RuntimeHostUpdateSelection): RuntimeHostUpdateCheckFrame { + return { + schemaVersion: 1, + kind: 'result', + action: 'check_update', + service: selection.service, + updateCheck: { + selector: selection.selector, + candidate: { + version: selection.candidate.version, + integrity: selection.candidate.integrity, }, + outcome: selection.outcome, }, }; } @@ -231,7 +259,11 @@ export async function resolveRuntimeHostRegistryUpdateCandidate( return invalidMetadata(); } const compatibility = positiveInteger(metadata[COMPATIBILITY_FIELD]); - return { version, integrity, ...(compatibility === undefined ? {} : { compatibility }) }; + return { + version, + integrity, + ...(compatibility === undefined ? {} : { compatibility }), + }; } async function readPackageCompatibility( diff --git a/packages/cli/src/runtime-host-update-package.ts b/packages/cli/src/runtime-host-update-package.ts index eb2354ff22..57739c0b1b 100644 --- a/packages/cli/src/runtime-host-update-package.ts +++ b/packages/cli/src/runtime-host-update-package.ts @@ -45,17 +45,13 @@ export class RuntimeHostUpdatePackageError extends Error { } } -export interface RuntimeHostAcquiredUpdatePackage { - readonly root: string; - cleanup(): Promise; -} - type RunNpm = (args: readonly string[], cwd: string) => Promise; -export async function acquireRuntimeHostRegistryUpdatePackage( +export async function withRuntimeHostRegistryUpdatePackage( candidate: RuntimeHostUpdateCandidate, + use: (packageRoot: string) => Promise, runNpm: RunNpm = runNpmCommand, -): Promise { +): Promise { if ( !isProductReleaseVersion(candidate.version) || !isSha512PackageIntegrity(candidate.integrity) || @@ -70,75 +66,76 @@ export async function acquireRuntimeHostRegistryUpdatePackage( const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-runtime-host-update-')); try { - const downloadRoot = join(temporaryRoot, 'download'); - const installRoot = join(temporaryRoot, 'install'); - const emptyCache = join(temporaryRoot, 'empty-cache'); - await mkdir(downloadRoot, { mode: 0o700 }); - const packed = await runNpm( - [ - 'pack', - `${PACKAGE_NAME}@${candidate.version}`, - '--pack-destination', - downloadRoot, - '--registry', - NPM_REGISTRY, - '--ignore-scripts', - ], - temporaryRoot, - ); - if (packed !== 0) { - throw new RuntimeHostUpdatePackageError( - 'package_download_failed', - `Unable to download Maka ${candidate.version} from the official npm registry`, + let packageRoot: string; + try { + const downloadRoot = join(temporaryRoot, 'download'); + const installRoot = join(temporaryRoot, 'install'); + const emptyCache = join(temporaryRoot, 'empty-cache'); + await mkdir(downloadRoot, { mode: 0o700 }); + const packed = await runNpm( + [ + 'pack', + `${PACKAGE_NAME}@${candidate.version}`, + '--pack-destination', + downloadRoot, + '--registry', + NPM_REGISTRY, + '--ignore-scripts', + ], + temporaryRoot, ); - } + if (packed !== 0) { + throw new RuntimeHostUpdatePackageError( + 'package_download_failed', + `Unable to download Maka ${candidate.version} from the official npm registry`, + ); + } - 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 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`, + ); + } - 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) { + packageRoot = await validateExtractedPackage(installRoot, candidate); + } catch (error) { + if (error instanceof RuntimeHostUpdatePackageError) throw error; throw new RuntimeHostUpdatePackageError( - 'invalid_package', - `Unable to extract the verified Maka ${candidate.version} package`, + 'package_download_failed', + `Unable to prepare Maka ${candidate.version} for a managed Runtime Host update`, + { cause: error }, ); } - - const packageRoot = await validateExtractedPackage(installRoot, candidate); - return { - root: packageRoot, - cleanup: () => rm(temporaryRoot, { recursive: true, force: true }), - }; - } catch (error) { + return await use(packageRoot); + } finally { await rm(temporaryRoot, { recursive: true, force: true }).catch(() => undefined); - if (error instanceof RuntimeHostUpdatePackageError) throw error; - throw new RuntimeHostUpdatePackageError( - 'package_download_failed', - `Unable to prepare Maka ${candidate.version} for a managed Runtime Host update`, - { cause: error }, - ); } } From b3cc055eba04f2b6fe0b8a5fbe0bfc7c70fa8511 Mon Sep 17 00:00:00 2001 From: Wang Date: Mon, 24 Aug 2026 17:17:34 +0800 Subject: [PATCH 3/8] fix(runtime-host): bind updates to deployment identity Fence selected updates by the current and target deployment paths so verified same-version packages cannot be skipped or admitted from stale compatibility evidence. Avoid deployment-lock reentry during forced recovery, and atomically detach package directories before cleanup so exact retries remain recoverable. Generated-by: Codex --- .../runtime-host-selected-update.test.ts | 2 + .../runtime-host-service-manager.test.ts | 111 +++++++++++++----- .../src/__tests__/runtime-host-setup.test.ts | 21 +++- .../src/runtime-host-managed-deployment.ts | 56 +++++++-- .../cli/src/runtime-host-setup-command.ts | 9 +- .../cli/src/runtime-host-update-command.ts | 106 +++++++++++------ .../cli/src/runtime-host-update-discovery.ts | 4 +- 7 files changed, 237 insertions(+), 72 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index 5f16cd51ca..ed6886bdb0 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -103,6 +103,7 @@ describe('managed Runtime Host selected update', () => { assert.equal(updateInput?.sourcePackageRoot, '/verified/package'); assert.equal(updateInput?.version, '2.0.0'); assert.equal(updateInput?.expectedCurrentVersion, '1.0.0'); + assert.equal(updateInput?.expectedCurrentCliPath, '/managed/versions/1.0.0/dist/cli.js'); assert.equal(updateInput?.packageIntegrity, INTEGRITY); }); @@ -155,6 +156,7 @@ function updateSelection( selector: OPTIONS.selector, candidate, outcome, + currentCliPath: `/managed/versions/${outcome.kind === 'current' ? candidate.version : '1.0.0'}/dist/cli.js`, service: { platform: 'linux', arch: 'x64', diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 21809769a9..b313fc10f8 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -43,8 +43,10 @@ import { import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; import { + prepareRuntimeHostManagedPackageDeployment, removeRuntimeHostManagedDeployment, resolveRuntimeHostManagedDeploymentRoot, + resolveRuntimeHostManagedPackageCliPath, } from '../runtime-host-managed-deployment.js'; import { runManagedRuntimeHostServiceCli } from '../runtime-host-service-management-command.js'; import { runManagedRuntimeHostUpdateCli } from '../runtime-host-update-command.js'; @@ -1300,7 +1302,11 @@ describe('managed Runtime Host service', () => { rootId: 'a'.repeat(64), }; const order: string[] = []; - const service = (version: string, state: 'running' | 'stopped') => + const service = ( + version: string, + state: 'running' | 'stopped', + cliPath = join(deploymentRoot, 'versions', version, 'dist', 'cli.js'), + ) => ({ schemaVersion: 1, action: 'status', @@ -1321,7 +1327,7 @@ describe('managed Runtime Host service', () => { websocket: { host: '127.0.0.1', port: 7400, path: '/runtime-host' }, launch: { nodePath: process.execPath, - cliPath: join(deploymentRoot, 'versions', version, 'dist', 'cli.js'), + cliPath, }, }, }, @@ -1329,6 +1335,7 @@ describe('managed Runtime Host service', () => { let statusReads = 0; let observedVersion = '1.0.0'; let observedState: 'running' | 'stopped' = 'running'; + let observedCliPath: string | undefined; let readyChecks = 0; let readyFailure = false; let operatorSupportsProcessLifetimeLock = false; @@ -1365,10 +1372,16 @@ describe('managed Runtime Host service', () => { legacyLeaseCalls += 1; return operation([]); }, - prepareDeployment: async () => ({ - version: '2.0.0', + prepareDeployment: async ( + input: Parameters[0], + ) => ({ + version: input.version, root: deploymentRoot, - cliPath: join(deploymentRoot, 'versions', '2.0.0', 'dist', 'cli.js'), + cliPath: resolveRuntimeHostManagedPackageCliPath( + deploymentRoot, + input.version, + input.packageIntegrity, + ), operatorPath: join(deploymentRoot, 'operator'), activate: async () => { assert.equal(insideLifecycle, true); @@ -1391,7 +1404,7 @@ describe('managed Runtime Host service', () => { }, ) => { const action = args[0]; - assert.ok(action === 'status' || action === 'retire' || action === 'stop'); + assert.ok(action === 'status' || action === 'retire'); if (action === 'status') { assert.equal( invocation?.capabilityRequest, @@ -1423,24 +1436,6 @@ describe('managed Runtime Host service', () => { } order.push(action); if (action === 'retire') assert.ok(args.includes('--allow-interrupt-active-tasks')); - if (action === 'stop') { - return { - schemaVersion: 1 as const, - kind: 'result' as const, - action: 'stop' as const, - service: { - platform: 'linux', - arch: 'x64', - osRelease: 'test', - state: 'stopped' as const, - pid: null, - lastExitCode: 3, - installedVersion: observedVersion, - stateRoot: expectedTarget.rootPath, - projectDirectoryRoots: [], - }, - }; - } if (operatorFailure) return operatorFailure; return { schemaVersion: 1 as const, @@ -1468,15 +1463,24 @@ describe('managed Runtime Host service', () => { order.push('cleanup'); }, manage: async (input: Parameters[0]) => { + if (input.action === 'stop') { + assert.equal(insideLifecycle, true); + order.push('stop'); + return service(observedVersion, 'stopped', observedCliPath); + } assert.equal(input.action, 'status'); statusReads += 1; if (statusReads > 1) assert.equal(insideLifecycle, true); - return service(observedVersion, statusReads === 1 ? observedState : 'stopped'); + return service( + observedVersion, + statusReads === 1 ? observedState : 'stopped', + observedCliPath, + ); }, - replace: async () => { + replace: async (input: Parameters[0]) => { assert.equal(insideLifecycle, true); order.push('replace'); - return service('2.0.0', 'running').service; + return service('2.0.0', 'running', input.cliPath).service; }, writeOutput: (value: string) => { output += value; @@ -1522,6 +1526,59 @@ describe('managed Runtime Host service', () => { assert.equal(readyChecks, 1); assert.deepEqual(order, ['cleanup']); + const localTargetCliPath = join(deploymentRoot, 'versions', '2.0.0', 'dist', 'cli.js'); + const packageIntegrity = + 'sha512-jUKdo/5dbM94KXq+kOZ1d+obhDLAENfI/QWr1PnXWcdu2PqDyLklJBtiVO6HRwoL1l40z1NE9Rq+hLAxCN0Fyg=='; + order.length = 0; + statusReads = 0; + output = ''; + assert.equal( + await runManagedRuntimeHostUpdateCli( + { + ...options, + packageIntegrity, + expectedCurrentVersion: '2.0.0', + expectedCurrentCliPath: localTargetCliPath, + }, + overrides, + ), + 0, + ); + assert.deepEqual(order, ['retire', 'activate', 'replace', 'cleanup']); + const identityUpdate = decodeRuntimeHostServiceManagementFrame( + output.trim().split('\n').at(-1) ?? '', + ); + assert.equal( + identityUpdate?.kind === 'result' && identityUpdate.action === 'update' + ? identityUpdate.update.kind + : undefined, + 'repaired', + ); + + order.length = 0; + statusReads = 0; + output = ''; + observedCliPath = join(deploymentRoot, 'versions', 'other', 'dist', 'cli.js'); + assert.equal( + await runManagedRuntimeHostUpdateCli( + { + ...options, + packageIntegrity, + expectedCurrentVersion: '2.0.0', + expectedCurrentCliPath: localTargetCliPath, + }, + overrides, + ), + 1, + ); + assert.deepEqual(order, []); + const staleIdentity = decodeRuntimeHostServiceManagementFrame(output.trim()); + assert.equal( + staleIdentity?.kind === 'error' ? staleIdentity.error.code : undefined, + 'target_mismatch', + ); + observedCliPath = undefined; + order.length = 0; statusReads = 0; output = ''; diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 6be34d8a1b..527e4ddf8f 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -26,6 +26,7 @@ import { readFile, readdir, realpath, + rename, rm, writeFile, } from 'node:fs/promises'; @@ -282,7 +283,7 @@ test('managed setup replaces one exact development package with another', async assert.deepEqual(await readdir(join(previousDeployment.root, 'versions')), [nextVersion]); }); -test('registry package identity does not reuse same-version local content', async (t) => { +test('registry package identity avoids local content and recovers an interrupted removal', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-registry-package-')); t.after(() => rm(base, { recursive: true, force: true })); const version = '0.2.0'; @@ -319,9 +320,25 @@ test('registry package identity does not reuse same-version local content', asyn assert.deepEqual(await readdir(dirname(dirname(dirname(registry.cliPath)))), [ basename(dirname(dirname(registry.cliPath))), ]); + + const registryRoot = dirname(dirname(registry.cliPath)); + const versionsRoot = dirname(registryRoot); + await rename(registryRoot, join(versionsRoot, `.${basename(registryRoot)}.interrupted.deleted`)); + const recovered = await prepareRuntimeHostManagedPackageDeployment( + { + serviceId, + clientDataRoot, + sourcePackageRoot: registryPackage, + version, + packageIntegrity: PACKAGE_INTEGRITY, + }, + pathOptions, + ); + assert.equal(await readFile(recovered.cliPath, 'utf8'), 'registry package\n'); + assert.deepEqual(await readdir(versionsRoot), [basename(registryRoot)]); }); -test('managed setup removes a newly copied package when service installation fails', async (t) => { +test('managed setup leaves no inactive package when service installation fails', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-failure-')); t.after(() => rm(base, { recursive: true, force: true })); const sourcePackageRoot = await createReleasePackage(base, '0.2.0'); diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index afa58b4cb6..53b61090dd 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -57,6 +57,16 @@ export interface RuntimeHostManagedPackageDeployment { rollback(): Promise; } +export function resolveRuntimeHostManagedPackageCliPath( + deploymentRoot: string, + version: string, + packageIntegrity?: string, +): string { + assertVersion(version); + const packageDirectory = packageIntegrity ? registryPackageDirectory(packageIntegrity) : version; + return join(resolve(deploymentRoot), 'versions', packageDirectory, 'dist', 'cli.js'); +} + export function isRuntimeHostDevelopmentPackageVersion(value: unknown): value is string { return typeof value === 'string' && /(?:-|\.)dev-[0-9a-f]{12}$/u.test(value); } @@ -81,14 +91,18 @@ export async function prepareRuntimeHostManagedPackageDeployment( ? registryPackageDirectory(input.packageIntegrity) : input.version; const packageRoot = join(versionsRoot, packageDirectory); - const cliPath = join(packageRoot, 'dist', 'cli.js'); + const cliPath = resolveRuntimeHostManagedPackageCliPath( + deploymentRoot, + input.version, + input.packageIntegrity, + ); const clientDataRoot = resolve(input.clientDataRoot); if (await pathExists(packageRoot)) { await validatePackage(packageRoot, input.version); return deployment(input.version, deploymentRoot, packageRoot, cliPath, clientDataRoot, false); } - await removeAbandonedStagingPackages(versionsRoot, packageDirectory); + await removeAbandonedPackageWorkspaces(versionsRoot, packageDirectory); const stagingRoot = join(versionsRoot, `.${packageDirectory}.${randomUUID()}.tmp`); try { await cp(sourcePackageRoot, stagingRoot, { @@ -118,14 +132,18 @@ export async function prepareRuntimeHostManagedPackageDeployment( } } -async function removeAbandonedStagingPackages( +async function removeAbandonedPackageWorkspaces( versionsRoot: string, - version: string, + packageDirectory: string, ): Promise { - const prefix = `.${version}.`; + const prefix = `.${packageDirectory}.`; await Promise.all( (await readdir(versionsRoot, { withFileTypes: true })) - .filter((entry) => entry.name.startsWith(prefix) && entry.name.endsWith('.tmp')) + .filter( + (entry) => + entry.name.startsWith(prefix) && + (entry.name.endsWith('.tmp') || entry.name.endsWith('.deleted')), + ) .map((entry) => rm(join(versionsRoot, entry.name), { recursive: true, force: true })), ); } @@ -298,7 +316,9 @@ function deployment( activate: () => writeOperatorLauncher(operatorPath, process.execPath, cliPath, clientDataRoot), cleanup: () => pruneInactivePackages(dirname(packageRoot), basename(packageRoot)), rollback: () => - created ? rm(packageRoot, { recursive: true, force: true }) : Promise.resolve(), + created + ? removePackageAtomically(dirname(packageRoot), basename(packageRoot)) + : Promise.resolve(), }; } @@ -350,10 +370,30 @@ async function pruneInactivePackages(versionsRoot: string, retainedPackage: stri await Promise.all( (await readdir(versionsRoot, { withFileTypes: true })) .filter((entry) => entry.name !== retainedPackage) - .map((entry) => rm(join(versionsRoot, entry.name), { recursive: true, force: true })), + .map((entry) => removePackageAtomically(versionsRoot, entry.name)), ); } +async function removePackageAtomically(versionsRoot: string, packageName: string): Promise { + const packageRoot = join(versionsRoot, packageName); + try { + if (packageName.startsWith('.') && packageName.endsWith('.deleted')) { + await rm(packageRoot, { recursive: true, force: true }); + return; + } + const tombstone = join(versionsRoot, `.${packageName}.${randomUUID()}.deleted`); + await rename(packageRoot, tombstone); + await rm(tombstone, { recursive: true, force: true }); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return; + throw new RuntimeHostManagedDeploymentError( + 'deployment_failed', + 'Unable to remove an inactive managed Runtime Host package', + { cause: error }, + ); + } +} + function registryPackageDirectory(integrity: string): string { if (!isSha512PackageIntegrity(integrity)) { throw new RuntimeHostManagedDeploymentError( diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index b5875bf42c..21a1491575 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -175,7 +175,14 @@ async function runRuntimeHostSetupLocked( backend, ); } catch (error) { - await deployment.rollback().catch(() => undefined); + try { + await deployment.rollback(); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'Runtime Host setup failed and its staged package could not be removed', + ); + } throw error; } const config = installed.service.config; diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 90e889031e..472637f076 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -18,7 +18,7 @@ */ import { spawn } from 'node:child_process'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { decodeRuntimeHostServiceManagementFrame, @@ -35,6 +35,7 @@ import { import { pruneRuntimeHostManagedDeploymentPackages, prepareRuntimeHostManagedPackageDeployment, + resolveRuntimeHostManagedPackageCliPath, RuntimeHostManagedDeploymentError, type RuntimeHostManagedPackageDeployment, } from './runtime-host-managed-deployment.js'; @@ -77,12 +78,20 @@ export interface RuntimeHostUpdateCliOptions { readonly version: string; readonly expectedTarget: RuntimeHostManagedServiceTarget; readonly expectedCurrentVersion?: string; + readonly expectedCurrentCliPath?: string; readonly packageIntegrity?: string; readonly allowInterruptActiveTasks?: boolean; } export interface RuntimeHostSelectedUpdateCliOptions - extends Omit { + extends Omit< + RuntimeHostUpdateCliOptions, + | 'sourcePackageRoot' + | 'version' + | 'expectedCurrentVersion' + | 'expectedCurrentCliPath' + | 'packageIntegrity' + > { readonly selector: RuntimeHostUpdateSelector; } @@ -178,16 +187,6 @@ export async function runManagedRuntimeHostUpdateCli( } as const; const status = await deps.manage({ ...common, action: 'status' }, backend); const currentVersion = requireManagedVersion(status); - if ( - options.expectedCurrentVersion && - currentVersion !== options.expectedCurrentVersion && - currentVersion !== options.version - ) { - throw new RuntimeHostServiceManagerError( - 'target_mismatch', - 'The managed Runtime Host changed after its update candidate was selected', - ); - } const serviceConfig = status.service.config; if (!serviceConfig?.managedDeploymentRoot) { throw new RuntimeHostServiceManagerError( @@ -195,9 +194,27 @@ export async function runManagedRuntimeHostUpdateCli( 'The Runtime Host service is not owned by a Maka managed deployment', ); } + const currentCliPath = resolve(serviceConfig.launch.cliPath); + const targetCliPath = resolveRuntimeHostManagedPackageCliPath( + serviceConfig.managedDeploymentRoot, + options.version, + options.packageIntegrity, + ); + const selectedDeploymentStillCurrent = + (!options.expectedCurrentVersion || currentVersion === options.expectedCurrentVersion) && + (!options.expectedCurrentCliPath || + currentCliPath === resolve(options.expectedCurrentCliPath)); + const targetDeploymentIsCurrent = + currentVersion === options.version && currentCliPath === targetCliPath; + if (!selectedDeploymentStillCurrent && !targetDeploymentIsCurrent) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The managed Runtime Host changed after its update candidate was selected', + ); + } emit(progress('checking', currentVersion, options.version)); let activeTargetNeedsRepair = false; - if (currentVersion === options.version && status.service.active) { + if (targetDeploymentIsCurrent && status.service.active) { try { await deps.verifyReady(serviceConfig, backend); } catch { @@ -247,6 +264,12 @@ export async function runManagedRuntimeHostUpdateCli( 'The staged Runtime Host package belongs to a different managed deployment', ); } + if (resolve(deployment.cliPath) !== targetCliPath) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The staged Runtime Host package does not match the selected deployment identity', + ); + } if (status.service.active) { emit(progress('retiring', currentVersion, options.version)); @@ -271,28 +294,25 @@ export async function runManagedRuntimeHostUpdateCli( if (!options.allowInterruptActiveTasks) { retirement = activeTasksRetirementFrame(status); } else { - const forced = await runCurrentOperator([ - 'stop', - '--framed', - ...expectedTargetArgs(options.expectedTarget), - ]); - if (forced.kind === 'error') { - emit({ ...forced, action: 'update' }); - return 1; - } - if (forced.kind !== 'result' || forced.action !== 'stop') { - throw new Error( - 'The current Runtime Host operator returned an invalid stop result', + const forced = await deps.withLifecycleLock(options.clientDataRoot, () => + deps.manage({ ...common, action: 'stop' }, backend), + ); + if ( + forced.service.active || + forced.service.pid !== null || + forced.service.state !== 'stopped' + ) { + throw new RuntimeHostServiceManagerError( + 'retirement_failed', + 'The unreachable Runtime Host did not reach a stable stopped state', ); } retirement = { schemaVersion: 1, kind: 'result', action: 'retire', - service: forced.service, - ...(forced.operatorCapabilities - ? { operatorCapabilities: forced.operatorCapabilities } - : {}), + service: runtimeHostServiceSummary(forced), + ...operatorCapabilities(), retirement: { kind: 'stopped' }, }; } @@ -303,8 +323,9 @@ export async function runManagedRuntimeHostUpdateCli( 'The current Runtime Host operator returned an unrelated retirement error', ); } - await deployment.rollback().catch(() => undefined); + const staged = deployment; deployment = undefined; + await staged.rollback(); emit({ ...retirement, action: 'update' }); return 1; } @@ -314,8 +335,9 @@ export async function runManagedRuntimeHostUpdateCli( ); } if (retirement.retirement.kind === 'active_tasks') { - await deployment.rollback().catch(() => undefined); + const staged = deployment; deployment = undefined; + await staged.rollback(); emit({ schemaVersion: 1, kind: 'result', @@ -354,7 +376,12 @@ export async function runManagedRuntimeHostUpdateCli( }, backend, ); - if (updatedService.installedVersion !== options.version || !updatedService.active) { + if ( + updatedService.installedVersion !== options.version || + !updatedService.active || + !updatedService.config || + resolve(updatedService.config.launch.cliPath) !== targetCliPath + ) { throw new RuntimeHostServiceManagerError( 'update_incomplete', 'The replacement Runtime Host did not report the selected package version as ready', @@ -380,7 +407,19 @@ export async function runManagedRuntimeHostUpdateCli( }); return 0; } catch (error) { - if (deployment && !cutoverStarted) await deployment.rollback().catch(() => undefined); + if (deployment && !cutoverStarted) { + const staged = deployment; + deployment = undefined; + try { + await staged.rollback(); + } catch (rollbackError) { + throw new RuntimeHostManagedDeploymentError( + 'deployment_failed', + 'The Runtime Host update failed and its staged package could not be removed', + { cause: new AggregateError([error, rollbackError]) }, + ); + } + } if ( (retired || cutoverStarted) && !(error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete') @@ -474,6 +513,7 @@ export async function runManagedRuntimeHostSelectedUpdateCli( sourcePackageRoot: packageRoot, version: selection.candidate.version, expectedCurrentVersion: selection.service.installedVersion, + expectedCurrentCliPath: selection.currentCliPath, packageIntegrity: selection.candidate.integrity, }); }); diff --git a/packages/cli/src/runtime-host-update-discovery.ts b/packages/cli/src/runtime-host-update-discovery.ts index 1ed6c1b332..a04f9d020f 100644 --- a/packages/cli/src/runtime-host-update-discovery.ts +++ b/packages/cli/src/runtime-host-update-discovery.ts @@ -20,7 +20,7 @@ import { spawn } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { compareProductReleaseVersions, @@ -106,6 +106,7 @@ export async function runManagedRuntimeHostUpdateCheckCli( export interface RuntimeHostUpdateSelection { readonly service: RuntimeHostUpdateCheckFrame['service']; + readonly currentCliPath: string; readonly selector: RuntimeHostUpdateSelector; readonly candidate: RuntimeHostUpdateCandidate; readonly outcome: RuntimeHostUpdateCheck['outcome']; @@ -167,6 +168,7 @@ async function resolveManagedRuntimeHostUpdate( state: serviceState, installedVersion: currentVersion, }, + currentCliPath: resolve(config.launch.cliPath), selector: options.selector, candidate, outcome: assessment, From bfd82ddd8195dbecf2f38ff37658df9df82ae4c6 Mon Sep 17 00:00:00 2001 From: Wang Date: Mon, 24 Aug 2026 17:53:02 +0800 Subject: [PATCH 4/8] fix(runtime-host): preserve update recovery identity Treat exact package identity, not semver alone, as current so same-version registry artifacts still pass compatibility admission. Keep repeated setup on the active package while idempotently restoring its operator launcher, preserving exact recovery after interrupted setup or cutover. Generated-by: Codex --- .../runtime-host-selected-update.test.ts | 10 +- .../runtime-host-service-manager.test.ts | 72 +++++++++++++-- .../src/__tests__/runtime-host-setup.test.ts | 51 ++++++++++ .../runtime-host-update-discovery.test.ts | 61 ++++++------ .../src/runtime-host-managed-deployment.ts | 23 +++++ .../cli/src/runtime-host-setup-command.ts | 92 +++++++++++++++---- .../cli/src/runtime-host-update-command.ts | 87 +++++++++--------- .../cli/src/runtime-host-update-discovery.ts | 19 +++- .../src/operator/service-management-frame.ts | 39 ++++++-- 9 files changed, 342 insertions(+), 112 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index ed6886bdb0..ed048c3b7a 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -102,9 +102,13 @@ describe('managed Runtime Host selected update', () => { assert.equal(exitCode, 0); assert.equal(updateInput?.sourcePackageRoot, '/verified/package'); assert.equal(updateInput?.version, '2.0.0'); - assert.equal(updateInput?.expectedCurrentVersion, '1.0.0'); - assert.equal(updateInput?.expectedCurrentCliPath, '/managed/versions/1.0.0/dist/cli.js'); - assert.equal(updateInput?.packageIntegrity, INTEGRITY); + assert.deepEqual(updateInput?.registrySelection, { + integrity: INTEGRITY, + current: { + version: '1.0.0', + cliPath: '/managed/versions/1.0.0/dist/cli.js', + }, + }); }); it('lets the exact transaction decide whether a current candidate needs repair', async () => { diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index b313fc10f8..9c0a2f5a8c 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -1341,6 +1341,7 @@ describe('managed Runtime Host service', () => { let operatorSupportsProcessLifetimeLock = false; let legacyLeaseCalls = 0; let operatorFailure: Extract | undefined; + let replaceFailure = false; let insideLifecycle = false; let output = ''; const options = { @@ -1480,6 +1481,12 @@ describe('managed Runtime Host service', () => { replace: async (input: Parameters[0]) => { assert.equal(insideLifecycle, true); order.push('replace'); + if (replaceFailure) { + throw new RuntimeHostServiceManagerError( + 'update_incomplete', + 'The replacement did not become ready', + ); + } return service('2.0.0', 'running', input.cliPath).service; }, writeOutput: (value: string) => { @@ -1536,9 +1543,10 @@ describe('managed Runtime Host service', () => { await runManagedRuntimeHostUpdateCli( { ...options, - packageIntegrity, - expectedCurrentVersion: '2.0.0', - expectedCurrentCliPath: localTargetCliPath, + registrySelection: { + integrity: packageIntegrity, + current: { version: '2.0.0', cliPath: localTargetCliPath }, + }, }, overrides, ), @@ -1552,7 +1560,7 @@ describe('managed Runtime Host service', () => { identityUpdate?.kind === 'result' && identityUpdate.action === 'update' ? identityUpdate.update.kind : undefined, - 'repaired', + 'updated', ); order.length = 0; @@ -1563,9 +1571,10 @@ describe('managed Runtime Host service', () => { await runManagedRuntimeHostUpdateCli( { ...options, - packageIntegrity, - expectedCurrentVersion: '2.0.0', - expectedCurrentCliPath: localTargetCliPath, + registrySelection: { + integrity: packageIntegrity, + current: { version: '2.0.0', cliPath: localTargetCliPath }, + }, }, overrides, ), @@ -1626,13 +1635,58 @@ describe('managed Runtime Host service', () => { ); statusReads = 0; - observedVersion = '3.0.0'; operatorFailure = undefined; + replaceFailure = true; + output = ''; + order.length = 0; + assert.equal( + await runManagedRuntimeHostUpdateCli( + { + ...options, + json: true, + framed: false, + registrySelection: { + integrity: packageIntegrity, + current: { + version: '1.0.0', + cliPath: join(deploymentRoot, 'versions', '1.0.0', 'dist', 'cli.js'), + }, + }, + }, + overrides, + ), + 1, + ); + assert.deepEqual(order, ['retire', 'activate', 'replace']); + const incomplete = JSON.parse(output) as RuntimeHostServiceManagementFrame; + assert.equal( + incomplete.kind === 'error' ? incomplete.error.code : undefined, + 'update_incomplete', + ); + assert.equal( + incomplete.kind === 'error' && incomplete.action === 'update' + ? incomplete.retryTargetVersion + : undefined, + '2.0.0', + ); + + statusReads = 0; + observedVersion = '3.0.0'; + replaceFailure = false; output = ''; order.length = 0; assert.equal( await runManagedRuntimeHostUpdateCli( - { ...options, expectedCurrentVersion: '1.0.0' }, + { + ...options, + registrySelection: { + integrity: packageIntegrity, + current: { + version: '1.0.0', + cliPath: join(deploymentRoot, 'versions', '1.0.0', 'dist', 'cli.js'), + }, + }, + }, overrides, ), 1, diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 527e4ddf8f..83d807079b 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -336,6 +336,57 @@ test('registry package identity avoids local content and recovers an interrupted ); assert.equal(await readFile(recovered.cliPath, 'utf8'), 'registry package\n'); assert.deepEqual(await readdir(versionsRoot), [basename(registryRoot)]); + + const stateRoot = join(clientDataRoot, 'workspaces', 'default'); + const config: RuntimeHostManagedServiceConfig = { + schemaVersion: 1, + managedDeploymentRoot: recovered.root, + rootPath: stateRoot, + projectDirectoryRoots: [], + websocket: { host: '127.0.0.1', port: 42_111, path: '/runtime-host' }, + launch: { nodePath: process.execPath, cliPath: recovered.cliPath }, + }; + let installedCliPath = ''; + assert.equal( + await runRuntimeHostSetupCli( + { + json: true, + clientDataRoot, + defaultRootPath: stateRoot, + sourcePackageRoot: localPackage, + version, + principalId: 'desktop.client-1', + preset: 'desktop-client', + }, + { + createBackend: () => unusedBackend(), + manageService: async (input: { readonly action: string; readonly cliPath: string }) => { + if (input.action === 'status') return serviceResult('status', config, version); + installedCliPath = input.cliPath; + return serviceResult('install', config, version); + }, + prepareDeployment: async () => assert.fail('same-version setup must reuse the deployment'), + replaceCredential: async () => ({ + rootId: 'a'.repeat(64), + credential: 'new-secret', + credentialId: 'new-credential', + principalKind: 'remote_owner', + principalId: 'desktop.client-1', + operationGrants: ['host.status'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + }), + verifyCredential: async () => undefined, + writeOutput: () => undefined, + }, + ), + 0, + ); + assert.equal(installedCliPath, recovered.cliPath); + const repairedOperator = await readFile(join(recovered.root, 'operator'), 'utf8'); + assert.equal(repairedOperator.includes(recovered.cliPath), true); + assert.equal(repairedOperator.includes(clientDataRoot), true); + assert.deepEqual(await readdir(versionsRoot), [basename(registryRoot)]); }); test('managed setup leaves no inactive package when service installation fails', async (t) => { diff --git a/packages/cli/src/__tests__/runtime-host-update-discovery.test.ts b/packages/cli/src/__tests__/runtime-host-update-discovery.test.ts index 435767bb17..15eee14e88 100644 --- a/packages/cli/src/__tests__/runtime-host-update-discovery.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-discovery.test.ts @@ -140,40 +140,37 @@ describe('managed Runtime Host update discovery', () => { ); }); - it('admits only newer packages with matching compatibility evidence', () => { - assert.deepEqual( - assessRuntimeHostUpdate('1.0.0', undefined, { version: '1.0.0', integrity: INTEGRITY }), - { kind: 'current' }, - ); + it('admits only exact current or compatible target identities', () => { + const candidate = (version: string, compatibility?: number) => ({ + version, + integrity: INTEGRITY, + ...(compatibility === undefined ? {} : { compatibility }), + }); + assert.deepEqual(assessRuntimeHostUpdate('1.0.0', undefined, candidate('1.0.0'), true), { + kind: 'current', + }); assert.deepEqual( - assessRuntimeHostUpdate('1.0.0-beta.1', 4, { - version: '1.0.0-beta.2', - integrity: INTEGRITY, - compatibility: 4, - }), + assessRuntimeHostUpdate('1.0.0-beta.1', 4, candidate('1.0.0-beta.2', 4), false), { kind: 'unattended_update', compatibility: 4 }, ); - const manual = assessRuntimeHostUpdate('1.0.0', 4, { - version: '2.0.0', - integrity: INTEGRITY, + assert.deepEqual(assessRuntimeHostUpdate('1.0.0', 4, candidate('1.0.0', 4), false), { + kind: 'unattended_update', + compatibility: 4, + }); + assert.deepEqual(assessRuntimeHostUpdate('1.0.0', undefined, candidate('1.0.0', 4), false), { + kind: 'manual_action', + reason: 'current_compatibility_unknown', }); + const manual = assessRuntimeHostUpdate('1.0.0', 4, candidate('2.0.0'), false); assert.deepEqual(manual, { kind: 'manual_action', reason: 'target_compatibility_unknown' }); - assert.deepEqual( - assessRuntimeHostUpdate('1.0.0', 4, { - version: '0.9.0', - integrity: INTEGRITY, - compatibility: 4, - }), - { kind: 'manual_action', reason: 'target_not_newer' }, - ); - assert.deepEqual( - assessRuntimeHostUpdate('1.0.0', 4, { - version: '2.0.0', - integrity: INTEGRITY, - compatibility: 5, - }), - { kind: 'manual_action', reason: 'compatibility_mismatch' }, - ); + assert.deepEqual(assessRuntimeHostUpdate('1.0.0', 4, candidate('0.9.0', 4), false), { + kind: 'manual_action', + reason: 'target_not_newer', + }); + assert.deepEqual(assessRuntimeHostUpdate('1.0.0', 4, candidate('2.0.0', 5), false), { + kind: 'manual_action', + reason: 'compatibility_mismatch', + }); assert.match( formatRuntimeHostUpdateCheck({ ...FRAME, @@ -185,6 +182,12 @@ describe('managed Runtime Host update discovery', () => { it('rejects malformed or contradictory machine evidence', () => { assert.doesNotThrow(() => encodeRuntimeHostServiceManagementFrame(FRAME)); + assert.doesNotThrow(() => + encodeRuntimeHostServiceManagementFrame({ + ...FRAME, + service: { ...FRAME.service, installedVersion: '2.0.0' }, + }), + ); assert.throws(() => encodeRuntimeHostServiceManagementFrame({ ...FRAME, diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index 53b61090dd..4b163259d4 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -262,6 +262,29 @@ export async function pruneRuntimeHostManagedDeploymentPackages( await pruneInactivePackages(versionsRoot, pathFromVersions); } +export async function repairRuntimeHostManagedDeploymentOperator(input: { + readonly deploymentRoot: string; + readonly serviceId: string; + readonly clientDataRoot: string; + readonly cliPath: string; +}): Promise { + const deploymentRoot = await realpath(resolve(input.deploymentRoot)); + const cliPath = await realpath(input.cliPath); + if (!isRuntimeHostManagedDeploymentCli(deploymentRoot, input.serviceId, cliPath)) { + throw new RuntimeHostManagedDeploymentError( + 'deployment_failed', + 'Refusing to repair an operator for an invalid managed Runtime Host package', + ); + } + const operatorPath = join(deploymentRoot, 'operator'); + await writeOperatorLauncher( + operatorPath, + process.execPath, + cliPath, + resolve(input.clientDataRoot), + ); +} + async function validatePackage(path: string, version: string): Promise { let packageRoot: string; let manifest: unknown; diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index 21a1491575..19976861b8 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -38,9 +38,12 @@ import { type RuntimeHostAccessPreset, } from './runtime-host-access-command.js'; import { + isRuntimeHostManagedDeploymentCli, isRuntimeHostDevelopmentPackageVersion, prepareRuntimeHostManagedPackageDeployment, + repairRuntimeHostManagedDeploymentOperator, RuntimeHostManagedDeploymentError, + type RuntimeHostManagedPackageDeployment, } from './runtime-host-managed-deployment.js'; import { createPlatformRuntimeHostServiceBackend } from './runtime-host-service-management-command.js'; import { @@ -76,6 +79,7 @@ interface RuntimeHostSetupDeps { readonly manageService: typeof manageRuntimeHostService; readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend; readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment; + readonly repairOperator: typeof repairRuntimeHostManagedDeploymentOperator; readonly prepareCredential: typeof prepareRuntimeHostAccessCredential; readonly replaceCredential: typeof replaceRuntimeHostAccessCredential; readonly revokeCredential: typeof revokeRuntimeHostAccessCredential; @@ -103,6 +107,7 @@ export async function runRuntimeHostSetupCli( manageService: manageRuntimeHostService, createBackend: createPlatformRuntimeHostServiceBackend, prepareDeployment: prepareRuntimeHostManagedPackageDeployment, + repairOperator: repairRuntimeHostManagedDeploymentOperator, prepareCredential: prepareRuntimeHostAccessCredential, replaceCredential: replaceRuntimeHostAccessCredential, revokeCredential: revokeRuntimeHostAccessCredential, @@ -148,14 +153,24 @@ async function runRuntimeHostSetupLocked( } as const; const status = await deps.manageService({ ...common, action: 'status' }, backend); await assertCompatibleExistingVersion(status, options.version); + const currentPackage = currentManagedPackage(status, serviceId, options.version); emit({ kind: 'progress', phase: 'installing_package' }); - const deployment = await deps.prepareDeployment({ - serviceId, - clientDataRoot: options.clientDataRoot, - sourcePackageRoot: options.sourcePackageRoot, - version: options.version, - }); + let deployment: RuntimeHostManagedPackageDeployment | undefined; + let packagePaths = currentPackage; + if (!packagePaths) { + deployment = await deps.prepareDeployment({ + serviceId, + clientDataRoot: options.clientDataRoot, + sourcePackageRoot: options.sourcePackageRoot, + version: options.version, + }); + packagePaths = { + deploymentRoot: deployment.root, + cliPath: deployment.cliPath, + operatorPath: deployment.operatorPath, + }; + } emit({ kind: 'progress', phase: 'installing_service' }); let installed: RuntimeHostManagedServiceResult; @@ -164,7 +179,7 @@ async function runRuntimeHostSetupLocked( { ...common, action: 'install', - cliPath: deployment.cliPath, + cliPath: packagePaths.cliPath, ...(options.rootPath ? { rootPath: options.rootPath } : {}), ...(options.projectDirectoryRoots ? { projectDirectoryRoots: options.projectDirectoryRoots } @@ -175,13 +190,15 @@ async function runRuntimeHostSetupLocked( backend, ); } catch (error) { - try { - await deployment.rollback(); - } catch (rollbackError) { - throw new AggregateError( - [error, rollbackError], - 'Runtime Host setup failed and its staged package could not be removed', - ); + if (deployment) { + try { + await deployment.rollback(); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'Runtime Host setup failed and its staged package could not be removed', + ); + } } throw error; } @@ -192,8 +209,17 @@ async function runRuntimeHostSetupLocked( 'Managed Runtime Host service did not become ready', ); } - await deployment.activate(); - await deployment.cleanup(); + if (deployment) { + await deployment.activate(); + } else { + await deps.repairOperator({ + deploymentRoot: packagePaths.deploymentRoot, + serviceId, + clientDataRoot: options.clientDataRoot, + cliPath: packagePaths.cliPath, + }); + } + await deployment?.cleanup(); emit({ kind: 'progress', phase: 'pairing_client' }); let paired: Awaited>; @@ -228,9 +254,9 @@ async function runRuntimeHostSetupLocked( }); emit({ kind: 'complete', - version: deployment.version, + version: options.version, serviceId, - operatorPath: deployment.operatorPath, + operatorPath: packagePaths.operatorPath, rootPath: config.rootPath, rootId: paired.rootId, endpoint, @@ -255,6 +281,36 @@ async function runRuntimeHostSetupLocked( } } +function currentManagedPackage( + status: RuntimeHostManagedServiceResult, + serviceId: string, + version: string, +): + | { + readonly deploymentRoot: string; + readonly cliPath: string; + readonly operatorPath: string; + } + | undefined { + const config = status.service.config; + if ( + status.service.installedVersion !== version || + !config?.managedDeploymentRoot || + !isRuntimeHostManagedDeploymentCli( + config.managedDeploymentRoot, + serviceId, + config.launch.cliPath, + ) + ) { + return undefined; + } + return { + deploymentRoot: config.managedDeploymentRoot, + cliPath: config.launch.cliPath, + operatorPath: join(config.managedDeploymentRoot, 'operator'), + }; +} + async function assertCompatibleExistingVersion( status: RuntimeHostManagedServiceResult, version: string, diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 472637f076..6cb26ccfbb 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -77,21 +77,18 @@ export interface RuntimeHostUpdateCliOptions { readonly sourcePackageRoot: string; readonly version: string; readonly expectedTarget: RuntimeHostManagedServiceTarget; - readonly expectedCurrentVersion?: string; - readonly expectedCurrentCliPath?: string; - readonly packageIntegrity?: string; + readonly registrySelection?: { + readonly integrity: string; + readonly current: { + readonly version: string; + readonly cliPath: string; + }; + }; readonly allowInterruptActiveTasks?: boolean; } export interface RuntimeHostSelectedUpdateCliOptions - extends Omit< - RuntimeHostUpdateCliOptions, - | 'sourcePackageRoot' - | 'version' - | 'expectedCurrentVersion' - | 'expectedCurrentCliPath' - | 'packageIntegrity' - > { + extends Omit { readonly selector: RuntimeHostUpdateSelector; } @@ -198,12 +195,13 @@ export async function runManagedRuntimeHostUpdateCli( const targetCliPath = resolveRuntimeHostManagedPackageCliPath( serviceConfig.managedDeploymentRoot, options.version, - options.packageIntegrity, + options.registrySelection?.integrity, ); + const expectedCurrent = options.registrySelection?.current; const selectedDeploymentStillCurrent = - (!options.expectedCurrentVersion || currentVersion === options.expectedCurrentVersion) && - (!options.expectedCurrentCliPath || - currentCliPath === resolve(options.expectedCurrentCliPath)); + !expectedCurrent || + (currentVersion === expectedCurrent.version && + currentCliPath === resolve(expectedCurrent.cliPath)); const targetDeploymentIsCurrent = currentVersion === options.version && currentCliPath === targetCliPath; if (!selectedDeploymentStillCurrent && !targetDeploymentIsCurrent) { @@ -255,7 +253,9 @@ export async function runManagedRuntimeHostUpdateCli( clientDataRoot: options.clientDataRoot, sourcePackageRoot: options.sourcePackageRoot, version: options.version, - ...(options.packageIntegrity ? { packageIntegrity: options.packageIntegrity } : {}), + ...(options.registrySelection + ? { packageIntegrity: options.registrySelection.integrity } + : {}), }), ); if (deployment.root !== serviceConfig.managedDeploymentRoot) { @@ -396,14 +396,13 @@ export async function runManagedRuntimeHostUpdateCli( action: 'update', service: runtimeHostServiceSummary(updated), ...operatorCapabilities(), - update: - currentVersion === options.version - ? { kind: 'repaired', version: options.version } - : { - kind: 'updated', - previousVersion: currentVersion, - targetVersion: options.version, - }, + update: targetDeploymentIsCurrent + ? { kind: 'repaired', version: options.version } + : { + kind: 'updated', + previousVersion: currentVersion, + targetVersion: options.version, + }, }); return 0; } catch (error) { @@ -420,30 +419,29 @@ export async function runManagedRuntimeHostUpdateCli( ); } } - if ( - (retired || cutoverStarted) && - !(error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete') - ) { - throw new RuntimeHostServiceManagerError( - 'update_incomplete', - `The Runtime Host update may have started its cutover before it failed; retry the exact ${options.version} update to complete recovery`, - { cause: error }, - ); - } throw error; } }); } catch (error) { + const updateIncomplete = retired || cutoverStarted; + const reportedError = updateIncomplete + ? new RuntimeHostServiceManagerError( + 'update_incomplete', + `The Runtime Host update may have started its cutover before it failed; retry the exact ${options.version} update to complete recovery`, + { cause: error }, + ) + : error; const code = - error instanceof RuntimeHostServiceManagerError || - error instanceof RuntimeHostManagedDeploymentError - ? error.code + reportedError instanceof RuntimeHostServiceManagerError || + reportedError instanceof RuntimeHostManagedDeploymentError + ? reportedError.code : 'internal_service_error'; - const message = error instanceof Error ? error.message : String(error); + const message = reportedError instanceof Error ? reportedError.message : String(reportedError); emit({ schemaVersion: 1, kind: 'error', action: 'update', + ...(updateIncomplete ? { retryTargetVersion: options.version } : {}), error: { code: truncateUtf8(code, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES) || 'internal_service_error', @@ -512,9 +510,13 @@ export async function runManagedRuntimeHostSelectedUpdateCli( ...updateOptions, sourcePackageRoot: packageRoot, version: selection.candidate.version, - expectedCurrentVersion: selection.service.installedVersion, - expectedCurrentCliPath: selection.currentCliPath, - packageIntegrity: selection.candidate.integrity, + registrySelection: { + integrity: selection.candidate.integrity, + current: { + version: selection.service.installedVersion, + cliPath: selection.currentCliPath, + }, + }, }); }); } catch (error) { @@ -708,6 +710,9 @@ function humanResult( if (frame.update.kind === 'repaired') { return `Runtime Host ${frame.update.version} was restored to a ready state.`; } + if (frame.update.previousVersion === frame.update.targetVersion) { + return `Runtime Host package ${frame.update.targetVersion} was updated.`; + } return `Runtime Host was updated from ${frame.update.previousVersion} to ${frame.update.targetVersion}.`; } diff --git a/packages/cli/src/runtime-host-update-discovery.ts b/packages/cli/src/runtime-host-update-discovery.ts index a04f9d020f..ecb42ff576 100644 --- a/packages/cli/src/runtime-host-update-discovery.ts +++ b/packages/cli/src/runtime-host-update-discovery.ts @@ -41,6 +41,7 @@ import { createPlatformRuntimeHostServiceBackend, runtimeHostServiceSummary, } from './runtime-host-service-management-command.js'; +import { resolveRuntimeHostManagedPackageCliPath } from './runtime-host-managed-deployment.js'; import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; const PACKAGE_NAME = 'maka-agent'; @@ -161,14 +162,25 @@ async function resolveManagedRuntimeHostUpdate( resolveRuntimeHostRegistryUpdateCandidate(options.selector), readPackageCompatibility(config.launch.cliPath, currentVersion), ]); - const assessment = assessRuntimeHostUpdate(currentVersion, currentCompatibility, candidate); + const currentCliPath = resolve(config.launch.cliPath); + const targetCliPath = resolveRuntimeHostManagedPackageCliPath( + config.managedDeploymentRoot, + candidate.version, + candidate.integrity, + ); + const assessment = assessRuntimeHostUpdate( + currentVersion, + currentCompatibility, + candidate, + currentCliPath === targetCliPath, + ); return { service: { ...service, state: serviceState, installedVersion: currentVersion, }, - currentCliPath: resolve(config.launch.cliPath), + currentCliPath, selector: options.selector, candidate, outcome: assessment, @@ -196,9 +208,10 @@ export function assessRuntimeHostUpdate( currentVersion: string, currentCompatibility: number | undefined, candidate: RuntimeHostUpdateCandidate, + currentDeploymentMatchesCandidate: boolean, ): RuntimeHostUpdateCheck['outcome'] { const relation = compareProductReleaseVersions(candidate.version, currentVersion); - if (relation === 0) return { kind: 'current' }; + if (relation === 0 && currentDeploymentMatchesCandidate) return { kind: 'current' }; if (relation < 0) { return { kind: 'manual_action', reason: 'target_not_newer' }; } diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index 2ebe23afe5..6a77a55dc6 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -58,6 +58,17 @@ const NON_RETIRE_SERVICE_ACTIONS = [ 'logs', 'uninstall', ] as const; +const NON_UPDATE_SERVICE_ACTIONS = [ + 'install', + 'status', + 'start', + 'stop', + 'restart', + 'retire', + 'check_update', + 'logs', + 'uninstall', +] as const; const UPDATE_PHASES = ['checking', 'staging', 'retiring', 'replacing'] as const; const UPDATE_CHANNELS = ['latest', 'next'] as const; const MANUAL_ACTION_REASONS = [ @@ -174,6 +185,12 @@ const SERVICE_RESULT_COMMON = { retainedStateRoot: boundedString(PATH_MAX_BYTES).optional(), logs: boundedString(RUNTIME_HOST_SERVICE_LOG_MAX_BYTES).optional(), } as const; +const SERVICE_ERROR_SCHEMA = z + .object({ + code: boundedNonEmptyString(RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES), + message: boundedNonEmptyString(RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES), + }) + .strict(); const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ z @@ -204,10 +221,10 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ outcome.kind === 'current' ? relation === 0 : outcome.kind === 'unattended_update' - ? relation > 0 + ? relation >= 0 : outcome.reason === 'target_not_newer' ? relation < 0 - : relation > 0; + : relation >= 0; if (!consistent) { context.addIssue({ code: 'custom', @@ -267,13 +284,17 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ .object({ schemaVersion: z.literal(1), kind: z.literal('error'), - action: z.enum(SERVICE_ACTIONS), - error: z - .object({ - code: boundedNonEmptyString(RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES), - message: boundedNonEmptyString(RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES), - }) - .strict(), + action: z.literal('update'), + retryTargetVersion: boundedNonEmptyString(FIELD_MAX_BYTES).optional(), + error: SERVICE_ERROR_SCHEMA, + }) + .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('error'), + action: z.enum(NON_UPDATE_SERVICE_ACTIONS), + error: SERVICE_ERROR_SCHEMA, }) .strict(), ]); From c004b770b5f9f24620e902a4950118b4fc7ebcac Mon Sep 17 00:00:00 2001 From: Wang Date: Mon, 24 Aug 2026 19:57:24 +0800 Subject: [PATCH 5/8] fix(runtime-host): unify managed package recovery Route both staged and existing packages through one deployment lifecycle so operator repair, cleanup, rollback, and exact-target recovery share the same authority. Keep npm acquisition state inside the update workspace while preserving the separate offline extraction cache. Generated-by: Codex --- .../runtime-host-service-manager.test.ts | 104 +++++++++++++----- .../src/__tests__/runtime-host-setup.test.ts | 69 ++++++++---- .../runtime-host-update-package.test.ts | 5 + .../src/runtime-host-managed-deployment.ts | 88 ++++++++------- .../cli/src/runtime-host-setup-command.ts | 72 +++++------- .../cli/src/runtime-host-update-command.ts | 55 +++++---- .../cli/src/runtime-host-update-package.ts | 3 + 7 files changed, 239 insertions(+), 157 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 9c0a2f5a8c..1a4cdeef72 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -43,6 +43,7 @@ import { import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; import { + openRuntimeHostManagedPackageDeployment, prepareRuntimeHostManagedPackageDeployment, removeRuntimeHostManagedDeployment, resolveRuntimeHostManagedDeploymentRoot, @@ -1336,12 +1337,13 @@ describe('managed Runtime Host service', () => { let observedVersion = '1.0.0'; let observedState: 'running' | 'stopped' = 'running'; let observedCliPath: string | undefined; - let readyChecks = 0; let readyFailure = false; let operatorSupportsProcessLifetimeLock = false; let legacyLeaseCalls = 0; let operatorFailure: Extract | undefined; let replaceFailure = false; + let cleanupFailure = false; + let expectAllowInterruptActiveTasks = true; let insideLifecycle = false; let output = ''; const options = { @@ -1354,6 +1356,24 @@ describe('managed Runtime Host service', () => { expectedTarget, allowInterruptActiveTasks: true, } as const; + const deployment = (version: string, cliPath: string) => ({ + version, + root: deploymentRoot, + cliPath, + operatorPath: join(deploymentRoot, 'operator'), + activate: async () => { + assert.equal(insideLifecycle, true); + order.push('activate'); + operatorSupportsProcessLifetimeLock = true; + }, + cleanup: async () => { + order.push('cleanup'); + if (cleanupFailure) throw new Error('Injected package cleanup failure'); + }, + rollback: async () => { + order.push('rollback'); + }, + }); const overrides = { createBackend: createUnusedBackend, withLifecycleLock: async (_root: string, operation: () => Promise) => { @@ -1373,29 +1393,20 @@ describe('managed Runtime Host service', () => { legacyLeaseCalls += 1; return operation([]); }, + openDeployment: async ( + input: Parameters[0], + ) => deployment(input.version, input.cliPath), prepareDeployment: async ( input: Parameters[0], - ) => ({ - version: input.version, - root: deploymentRoot, - cliPath: resolveRuntimeHostManagedPackageCliPath( - deploymentRoot, + ) => + deployment( input.version, - input.packageIntegrity, + resolveRuntimeHostManagedPackageCliPath( + deploymentRoot, + input.version, + input.packageIntegrity, + ), ), - operatorPath: join(deploymentRoot, 'operator'), - activate: async () => { - assert.equal(insideLifecycle, true); - order.push('activate'); - operatorSupportsProcessLifetimeLock = true; - }, - cleanup: async () => { - order.push('cleanup'); - }, - rollback: async () => { - order.push('rollback'); - }, - }), runOperator: async ( _operatorPath: string, args: readonly string[], @@ -1436,7 +1447,12 @@ describe('managed Runtime Host service', () => { }; } order.push(action); - if (action === 'retire') assert.ok(args.includes('--allow-interrupt-active-tasks')); + if (action === 'retire') { + assert.equal( + args.includes('--allow-interrupt-active-tasks'), + expectAllowInterruptActiveTasks, + ); + } if (operatorFailure) return operatorFailure; return { schemaVersion: 1 as const, @@ -1457,12 +1473,8 @@ describe('managed Runtime Host service', () => { }; }, verifyReady: async () => { - readyChecks += 1; if (readyFailure) throw new Error('Host is active but not ready'); }, - prunePackages: async () => { - order.push('cleanup'); - }, manage: async (input: Parameters[0]) => { if (input.action === 'stop') { assert.equal(insideLifecycle, true); @@ -1530,9 +1542,30 @@ describe('managed Runtime Host service', () => { observedState = 'running'; output = ''; assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0); - assert.equal(readyChecks, 1); assert.deepEqual(order, ['cleanup']); + cleanupFailure = true; + order.length = 0; + statusReads = 0; + output = ''; + assert.equal( + await runManagedRuntimeHostUpdateCli({ ...options, json: true, framed: false }, overrides), + 1, + ); + assert.deepEqual(order, ['cleanup']); + const cleanupRecovery = JSON.parse(output) as RuntimeHostServiceManagementFrame; + assert.equal( + cleanupRecovery.kind === 'error' ? cleanupRecovery.error.code : undefined, + 'update_incomplete', + ); + assert.equal( + cleanupRecovery.kind === 'error' && cleanupRecovery.action === 'update' + ? cleanupRecovery.retryTargetVersion + : undefined, + '2.0.0', + ); + cleanupFailure = false; + const localTargetCliPath = join(deploymentRoot, 'versions', '2.0.0', 'dist', 'cli.js'); const packageIntegrity = 'sha512-jUKdo/5dbM94KXq+kOZ1d+obhDLAENfI/QWr1PnXWcdu2PqDyLklJBtiVO6HRwoL1l40z1NE9Rq+hLAxCN0Fyg=='; @@ -1593,7 +1626,6 @@ describe('managed Runtime Host service', () => { output = ''; readyFailure = true; assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0); - assert.equal(readyChecks, 2); assert.deepEqual(order, ['retire', 'activate', 'replace', 'cleanup']); assert.equal(legacyLeaseCalls, 1); const activeRecovery = decodeRuntimeHostServiceManagementFrame( @@ -1615,6 +1647,24 @@ describe('managed Runtime Host service', () => { action: 'retire', error: { code: 'retirement_failed', message: 'The active Host is not reachable' }, }; + expectAllowInterruptActiveTasks = false; + const { allowInterruptActiveTasks: _allowInterruptActiveTasks, ...safeOptions } = options; + assert.equal(await runManagedRuntimeHostUpdateCli(safeOptions, overrides), 1); + assert.deepEqual(order, ['retire', 'rollback']); + const activeTasks = decodeRuntimeHostServiceManagementFrame( + output.trim().split('\n').at(-1) ?? '', + ); + assert.equal( + activeTasks?.kind === 'result' && activeTasks.action === 'update' + ? activeTasks.update.kind + : undefined, + 'active_tasks', + ); + + order.length = 0; + statusReads = 0; + output = ''; + expectAllowInterruptActiveTasks = true; assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0); assert.deepEqual(order, ['retire', 'stop', 'activate', 'replace', 'cleanup']); assert.equal(legacyLeaseCalls, 1); diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 83d807079b..f68ec7f934 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -336,6 +336,7 @@ test('registry package identity avoids local content and recovers an interrupted ); assert.equal(await readFile(recovered.cliPath, 'utf8'), 'registry package\n'); assert.deepEqual(await readdir(versionsRoot), [basename(registryRoot)]); + await mkdir(join(versionsRoot, 'stale-package')); const stateRoot = join(clientDataRoot, 'workspaces', 'default'); const config: RuntimeHostManagedServiceConfig = { @@ -401,30 +402,45 @@ test('managed setup leaves no inactive package when service installation fails', platform: 'linux' as const, }; const outputs: string[] = []; - const exitCode = await runRuntimeHostSetupCli( - { - json: true, - clientDataRoot, - defaultRootPath: join(clientDataRoot, 'workspaces', 'default'), - sourcePackageRoot, - version: '0.2.0', - principalId: 'desktop.client-1', - preset: 'desktop-client', + let rollbackFails = false; + const options = { + json: true, + clientDataRoot, + defaultRootPath: join(clientDataRoot, 'workspaces', 'default'), + sourcePackageRoot, + version: '0.2.0', + principalId: 'desktop.client-1', + preset: 'desktop-client', + } as const; + const overrides = { + createBackend: () => unusedBackend(), + manageService: async (input: { readonly action: string }) => { + if (input.action === 'status') return serviceResult('status', null, null); + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + `Injected service failure ${'x'.repeat(2_000)}`, + ); }, - { - createBackend: () => unusedBackend(), - manageService: async (input: { readonly action: string }) => { - if (input.action === 'status') return serviceResult('status', null, null); - throw new RuntimeHostServiceManagerError( - 'service_manager_operation_failed', - `Injected service failure ${'x'.repeat(2_000)}`, - ); - }, - prepareDeployment: (input) => - prepareRuntimeHostManagedPackageDeployment(input, deploymentPathOptions), - writeOutput: (value) => outputs.push(value), + prepareDeployment: async ( + input: Parameters[0], + ) => { + const deployment = await prepareRuntimeHostManagedPackageDeployment( + input, + deploymentPathOptions, + ); + return rollbackFails + ? { + ...deployment, + rollback: async () => { + await deployment.rollback(); + throw new Error('Injected rollback failure'); + }, + } + : deployment; }, - ); + writeOutput: (value: string) => outputs.push(value), + }; + const exitCode = await runRuntimeHostSetupCli(options, overrides); assert.equal(exitCode, 1); const failure = decodeRuntimeHostSetupFrame(outputs.at(-1) ?? ''); assert.equal(failure?.kind, 'error'); @@ -441,6 +457,15 @@ test('managed setup leaves no inactive package when service installation fails', ), ), ); + + rollbackFails = true; + outputs.length = 0; + assert.equal(await runRuntimeHostSetupCli(options, overrides), 1); + const rollbackFailure = decodeRuntimeHostSetupFrame(outputs.at(-1) ?? ''); + assert.deepEqual(rollbackFailure?.kind === 'error' ? rollbackFailure.error : undefined, { + code: 'deployment_failed', + message: 'Runtime Host setup failed and its staged package could not be removed', + }); }); test('managed operator binds its Client Data Root and routes deployment cleanup', { 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 d29a67badd..c096f2304a 100644 --- a/packages/cli/src/__tests__/runtime-host-update-package.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-package.test.ts @@ -80,6 +80,11 @@ describe('managed Runtime Host update package acquisition', () => { assert.deepEqual(calls[0]?.slice(0, 2), ['pack', 'maka-agent@2.0.0']); assert.equal(calls[0]?.includes('https://registry.npmjs.org/'), true); + const downloadCache = calls[0]?.[calls[0].indexOf('--cache') + 1]; + const installCache = calls[1]?.[calls[1].indexOf('--cache') + 1]; + assert.match(downloadCache ?? '', /download-cache$/u); + assert.match(installCache ?? '', /empty-cache$/u); + assert.notEqual(downloadCache, installCache); assert.equal(calls[1]?.includes('--offline'), true); assert.equal(calls[1]?.includes('--ignore-scripts'), true); assert.equal(calls[1]?.includes('http://127.0.0.1:9/'), true); diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index 4b163259d4..fdf0d73152 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -132,6 +132,49 @@ export async function prepareRuntimeHostManagedPackageDeployment( } } +export async function openRuntimeHostManagedPackageDeployment(input: { + readonly serviceId: string; + readonly clientDataRoot: string; + readonly deploymentRoot: string; + readonly cliPath: string; + readonly version: string; +}): Promise { + assertVersion(input.version); + let deploymentRoot: string; + let cliPath: string; + try { + deploymentRoot = await realpath(resolve(input.deploymentRoot)); + cliPath = await realpath(input.cliPath); + } catch (error) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_package', + `The managed Maka ${input.version} package is unavailable`, + { cause: error }, + ); + } + if (resolveRuntimeHostManagedDeploymentForCli(input.serviceId, cliPath) !== deploymentRoot) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_package', + 'The configured Runtime Host package does not belong to its managed deployment', + ); + } + const packageRoot = await validatePackage(dirname(dirname(cliPath)), input.version); + if (cliPath !== join(packageRoot, 'dist', 'cli.js')) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_package', + 'The configured Runtime Host CLI does not match its managed package', + ); + } + return deployment( + input.version, + deploymentRoot, + packageRoot, + cliPath, + resolve(input.clientDataRoot), + false, + ); +} + async function removeAbandonedPackageWorkspaces( versionsRoot: string, packageDirectory: string, @@ -240,51 +283,6 @@ export async function removeRuntimeHostManagedDeployment( await rm(requestedRoot, { recursive: true, force: true }); } -export async function pruneRuntimeHostManagedDeploymentPackages( - deploymentRoot: string, - retainedCliPath: string, -): Promise { - const versionsRoot = join(await realpath(resolve(deploymentRoot)), 'versions'); - const packageRoot = dirname(dirname(await realpath(retainedCliPath))); - const pathFromVersions = relative(versionsRoot, packageRoot); - if ( - pathFromVersions === '' || - pathFromVersions === '..' || - pathFromVersions.startsWith(`..${sep}`) || - isAbsolute(pathFromVersions) || - pathFromVersions.includes(sep) - ) { - throw new RuntimeHostManagedDeploymentError( - 'deployment_failed', - 'Refusing to prune packages for an invalid managed Runtime Host CLI path', - ); - } - await pruneInactivePackages(versionsRoot, pathFromVersions); -} - -export async function repairRuntimeHostManagedDeploymentOperator(input: { - readonly deploymentRoot: string; - readonly serviceId: string; - readonly clientDataRoot: string; - readonly cliPath: string; -}): Promise { - const deploymentRoot = await realpath(resolve(input.deploymentRoot)); - const cliPath = await realpath(input.cliPath); - if (!isRuntimeHostManagedDeploymentCli(deploymentRoot, input.serviceId, cliPath)) { - throw new RuntimeHostManagedDeploymentError( - 'deployment_failed', - 'Refusing to repair an operator for an invalid managed Runtime Host package', - ); - } - const operatorPath = join(deploymentRoot, 'operator'); - await writeOperatorLauncher( - operatorPath, - process.execPath, - cliPath, - resolve(input.clientDataRoot), - ); -} - async function validatePackage(path: string, version: string): Promise { let packageRoot: string; let manifest: unknown; diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index 19976861b8..dac7928d1a 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -40,10 +40,9 @@ import { import { isRuntimeHostManagedDeploymentCli, isRuntimeHostDevelopmentPackageVersion, + openRuntimeHostManagedPackageDeployment, prepareRuntimeHostManagedPackageDeployment, - repairRuntimeHostManagedDeploymentOperator, RuntimeHostManagedDeploymentError, - type RuntimeHostManagedPackageDeployment, } from './runtime-host-managed-deployment.js'; import { createPlatformRuntimeHostServiceBackend } from './runtime-host-service-management-command.js'; import { @@ -78,8 +77,8 @@ export interface RuntimeHostSetupCliOptions { interface RuntimeHostSetupDeps { readonly manageService: typeof manageRuntimeHostService; readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend; + readonly openDeployment: typeof openRuntimeHostManagedPackageDeployment; readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment; - readonly repairOperator: typeof repairRuntimeHostManagedDeploymentOperator; readonly prepareCredential: typeof prepareRuntimeHostAccessCredential; readonly replaceCredential: typeof replaceRuntimeHostAccessCredential; readonly revokeCredential: typeof revokeRuntimeHostAccessCredential; @@ -106,8 +105,8 @@ export async function runRuntimeHostSetupCli( const deps: RuntimeHostSetupDeps = { manageService: manageRuntimeHostService, createBackend: createPlatformRuntimeHostServiceBackend, + openDeployment: openRuntimeHostManagedPackageDeployment, prepareDeployment: prepareRuntimeHostManagedPackageDeployment, - repairOperator: repairRuntimeHostManagedDeploymentOperator, prepareCredential: prepareRuntimeHostAccessCredential, replaceCredential: replaceRuntimeHostAccessCredential, revokeCredential: revokeRuntimeHostAccessCredential, @@ -156,21 +155,20 @@ async function runRuntimeHostSetupLocked( const currentPackage = currentManagedPackage(status, serviceId, options.version); emit({ kind: 'progress', phase: 'installing_package' }); - let deployment: RuntimeHostManagedPackageDeployment | undefined; - let packagePaths = currentPackage; - if (!packagePaths) { - deployment = await deps.prepareDeployment({ - serviceId, - clientDataRoot: options.clientDataRoot, - sourcePackageRoot: options.sourcePackageRoot, - version: options.version, - }); - packagePaths = { - deploymentRoot: deployment.root, - cliPath: deployment.cliPath, - operatorPath: deployment.operatorPath, - }; - } + const deployment = currentPackage + ? await deps.openDeployment({ + serviceId, + clientDataRoot: options.clientDataRoot, + deploymentRoot: currentPackage.deploymentRoot, + cliPath: currentPackage.cliPath, + version: options.version, + }) + : await deps.prepareDeployment({ + serviceId, + clientDataRoot: options.clientDataRoot, + sourcePackageRoot: options.sourcePackageRoot, + version: options.version, + }); emit({ kind: 'progress', phase: 'installing_service' }); let installed: RuntimeHostManagedServiceResult; @@ -179,7 +177,7 @@ async function runRuntimeHostSetupLocked( { ...common, action: 'install', - cliPath: packagePaths.cliPath, + cliPath: deployment.cliPath, ...(options.rootPath ? { rootPath: options.rootPath } : {}), ...(options.projectDirectoryRoots ? { projectDirectoryRoots: options.projectDirectoryRoots } @@ -190,15 +188,14 @@ async function runRuntimeHostSetupLocked( backend, ); } catch (error) { - if (deployment) { - try { - await deployment.rollback(); - } catch (rollbackError) { - throw new AggregateError( - [error, rollbackError], - 'Runtime Host setup failed and its staged package could not be removed', - ); - } + try { + await deployment.rollback(); + } catch (rollbackError) { + throw new RuntimeHostSetupError( + 'deployment_failed', + 'Runtime Host setup failed and its staged package could not be removed', + { cause: new AggregateError([error, rollbackError]) }, + ); } throw error; } @@ -209,17 +206,8 @@ async function runRuntimeHostSetupLocked( 'Managed Runtime Host service did not become ready', ); } - if (deployment) { - await deployment.activate(); - } else { - await deps.repairOperator({ - deploymentRoot: packagePaths.deploymentRoot, - serviceId, - clientDataRoot: options.clientDataRoot, - cliPath: packagePaths.cliPath, - }); - } - await deployment?.cleanup(); + await deployment.activate(); + await deployment.cleanup(); emit({ kind: 'progress', phase: 'pairing_client' }); let paired: Awaited>; @@ -256,7 +244,7 @@ async function runRuntimeHostSetupLocked( kind: 'complete', version: options.version, serviceId, - operatorPath: packagePaths.operatorPath, + operatorPath: deployment.operatorPath, rootPath: config.rootPath, rootId: paired.rootId, endpoint, @@ -289,7 +277,6 @@ function currentManagedPackage( | { readonly deploymentRoot: string; readonly cliPath: string; - readonly operatorPath: string; } | undefined { const config = status.service.config; @@ -307,7 +294,6 @@ function currentManagedPackage( return { deploymentRoot: config.managedDeploymentRoot, cliPath: config.launch.cliPath, - operatorPath: join(config.managedDeploymentRoot, 'operator'), }; } diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 6cb26ccfbb..796902452e 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -33,7 +33,7 @@ import { type RuntimeHostServiceUpdatePhase, } from '@maka/runtime-host/operator'; import { - pruneRuntimeHostManagedDeploymentPackages, + openRuntimeHostManagedPackageDeployment, prepareRuntimeHostManagedPackageDeployment, resolveRuntimeHostManagedPackageCliPath, RuntimeHostManagedDeploymentError, @@ -95,13 +95,13 @@ export interface RuntimeHostSelectedUpdateCliOptions interface RuntimeHostUpdateCliDeps { readonly manage: typeof manageRuntimeHostService; readonly replace: typeof replaceRuntimeHostManagedService; + readonly openDeployment: typeof openRuntimeHostManagedPackageDeployment; readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment; readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock; readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock; readonly withLegacyOperatorLeases: typeof withRuntimeHostManagedServiceLegacyOperatorLeases; readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend; readonly verifyReady: typeof verifyRuntimeHostManagedServiceReady; - readonly prunePackages: typeof pruneRuntimeHostManagedDeploymentPackages; readonly runOperator: ( operatorPath: string, args: readonly string[], @@ -131,19 +131,20 @@ export async function runManagedRuntimeHostUpdateCli( const deps: RuntimeHostUpdateCliDeps = { manage: manageRuntimeHostService, replace: replaceRuntimeHostManagedService, + openDeployment: openRuntimeHostManagedPackageDeployment, prepareDeployment: prepareRuntimeHostManagedPackageDeployment, withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock, withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock, withLegacyOperatorLeases: withRuntimeHostManagedServiceLegacyOperatorLeases, createBackend: createPlatformRuntimeHostServiceBackend, verifyReady: verifyRuntimeHostManagedServiceReady, - prunePackages: pruneRuntimeHostManagedDeploymentPackages, runOperator: runManagedRuntimeHostOperator, writeOutput: (value) => process.stdout.write(value), writeError: (value) => process.stderr.write(value), ...overrides, }; let deployment: RuntimeHostManagedPackageDeployment | undefined; + let exactTargetObserved = false; let cutoverStarted = false; let retired = false; const emit = (frame: RuntimeHostServiceManagementFrame): void => { @@ -191,9 +192,10 @@ export async function runManagedRuntimeHostUpdateCli( 'The Runtime Host service is not owned by a Maka managed deployment', ); } + const deploymentRoot = serviceConfig.managedDeploymentRoot; const currentCliPath = resolve(serviceConfig.launch.cliPath); const targetCliPath = resolveRuntimeHostManagedPackageCliPath( - serviceConfig.managedDeploymentRoot, + deploymentRoot, options.version, options.registrySelection?.integrity, ); @@ -204,6 +206,7 @@ export async function runManagedRuntimeHostUpdateCli( currentCliPath === resolve(expectedCurrent.cliPath)); const targetDeploymentIsCurrent = currentVersion === options.version && currentCliPath === targetCliPath; + exactTargetObserved = targetDeploymentIsCurrent; if (!selectedDeploymentStillCurrent && !targetDeploymentIsCurrent) { throw new RuntimeHostServiceManagerError( 'target_mismatch', @@ -219,10 +222,14 @@ export async function runManagedRuntimeHostUpdateCli( activeTargetNeedsRepair = true; } if (!activeTargetNeedsRepair) { - await deps.prunePackages( - serviceConfig.managedDeploymentRoot, - serviceConfig.launch.cliPath, - ); + const currentDeployment = await deps.openDeployment({ + serviceId, + clientDataRoot: options.clientDataRoot, + deploymentRoot, + cliPath: serviceConfig.launch.cliPath, + version: options.version, + }); + await currentDeployment.cleanup(); emit({ schemaVersion: 1, kind: 'result', @@ -248,17 +255,25 @@ export async function runManagedRuntimeHostUpdateCli( emit(progress('staging', currentVersion, options.version)); deployment = await deps.withLifecycleLock(options.clientDataRoot, () => - deps.prepareDeployment({ - serviceId, - clientDataRoot: options.clientDataRoot, - sourcePackageRoot: options.sourcePackageRoot, - version: options.version, - ...(options.registrySelection - ? { packageIntegrity: options.registrySelection.integrity } - : {}), - }), + targetDeploymentIsCurrent + ? deps.openDeployment({ + serviceId, + clientDataRoot: options.clientDataRoot, + deploymentRoot, + cliPath: serviceConfig.launch.cliPath, + version: options.version, + }) + : deps.prepareDeployment({ + serviceId, + clientDataRoot: options.clientDataRoot, + sourcePackageRoot: options.sourcePackageRoot, + version: options.version, + ...(options.registrySelection + ? { packageIntegrity: options.registrySelection.integrity } + : {}), + }), ); - if (deployment.root !== serviceConfig.managedDeploymentRoot) { + if (deployment.root !== deploymentRoot) { throw new RuntimeHostServiceManagerError( 'target_mismatch', 'The staged Runtime Host package belongs to a different managed deployment', @@ -423,11 +438,11 @@ export async function runManagedRuntimeHostUpdateCli( } }); } catch (error) { - const updateIncomplete = retired || cutoverStarted; + const updateIncomplete = exactTargetObserved || retired || cutoverStarted; const reportedError = updateIncomplete ? new RuntimeHostServiceManagerError( 'update_incomplete', - `The Runtime Host update may have started its cutover before it failed; retry the exact ${options.version} update to complete recovery`, + `The Runtime Host update did not complete; retry the exact ${options.version} update to complete recovery`, { cause: error }, ) : error; diff --git a/packages/cli/src/runtime-host-update-package.ts b/packages/cli/src/runtime-host-update-package.ts index 57739c0b1b..55f626b983 100644 --- a/packages/cli/src/runtime-host-update-package.ts +++ b/packages/cli/src/runtime-host-update-package.ts @@ -69,6 +69,7 @@ export async function withRuntimeHostRegistryUpdatePackage( let packageRoot: 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 }); @@ -80,6 +81,8 @@ export async function withRuntimeHostRegistryUpdatePackage( downloadRoot, '--registry', NPM_REGISTRY, + '--cache', + downloadCache, '--ignore-scripts', ], temporaryRoot, From 0af517d93dd153b1923784e09351ac121b06c711 Mon Sep 17 00:00:00 2001 From: Wang Date: Mon, 24 Aug 2026 22:36:39 +0800 Subject: [PATCH 6/8] fix(runtime-host): remove stale update retry hint Let each update invocation observe the managed installation and reconcile its current desired deployment instead of exposing a version-only recovery authority that cannot identify an exact package. Generated-by: Codex --- .../__tests__/runtime-host-service-manager.test.ts | 12 ------------ packages/cli/src/runtime-host-update-command.ts | 3 +-- .../src/operator/service-management-frame.ts | 1 - 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 1a4cdeef72..f5ab1e8c59 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -1558,12 +1558,6 @@ describe('managed Runtime Host service', () => { cleanupRecovery.kind === 'error' ? cleanupRecovery.error.code : undefined, 'update_incomplete', ); - assert.equal( - cleanupRecovery.kind === 'error' && cleanupRecovery.action === 'update' - ? cleanupRecovery.retryTargetVersion - : undefined, - '2.0.0', - ); cleanupFailure = false; const localTargetCliPath = join(deploymentRoot, 'versions', '2.0.0', 'dist', 'cli.js'); @@ -1713,12 +1707,6 @@ describe('managed Runtime Host service', () => { incomplete.kind === 'error' ? incomplete.error.code : undefined, 'update_incomplete', ); - assert.equal( - incomplete.kind === 'error' && incomplete.action === 'update' - ? incomplete.retryTargetVersion - : undefined, - '2.0.0', - ); statusReads = 0; observedVersion = '3.0.0'; diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 796902452e..f21a804663 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -442,7 +442,7 @@ export async function runManagedRuntimeHostUpdateCli( const reportedError = updateIncomplete ? new RuntimeHostServiceManagerError( 'update_incomplete', - `The Runtime Host update did not complete; retry the exact ${options.version} update to complete recovery`, + 'The Runtime Host update did not complete; run the update again to reconcile the managed installation', { cause: error }, ) : error; @@ -456,7 +456,6 @@ export async function runManagedRuntimeHostUpdateCli( schemaVersion: 1, kind: 'error', action: 'update', - ...(updateIncomplete ? { retryTargetVersion: options.version } : {}), error: { code: truncateUtf8(code, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES) || 'internal_service_error', diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index 6a77a55dc6..c857bef788 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -285,7 +285,6 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ schemaVersion: z.literal(1), kind: z.literal('error'), action: z.literal('update'), - retryTargetVersion: boundedNonEmptyString(FIELD_MAX_BYTES).optional(), error: SERVICE_ERROR_SCHEMA, }) .strict(), From a5484715b330d0b6af3b38d90c54a7231a7b3b70 Mon Sep 17 00:00:00 2001 From: Wang Date: Mon, 24 Aug 2026 23:17:10 +0800 Subject: [PATCH 7/8] fix(runtime-host): restore uncommitted service replacements Distinguish a service-manager replacement that never committed from a target that committed but did not become ready. Restore the previous backend definition and config only in the former case; retain and stop the selected deployment when readiness leaves storage compatibility unknown. Generated-by: Codex --- .../runtime-host-launch-agent-service.test.ts | 42 ++++++++++--------- .../runtime-host-service-manager.test.ts | 38 +++++++++++++++-- .../src/runtime-host-launch-agent-service.ts | 12 +++++- .../cli/src/runtime-host-service-manager.ts | 29 ++++++++++++- .../cli/src/runtime-host-systemd-service.ts | 12 +++++- 5 files changed, 104 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts index 0d7a3e9343..4bb50c7708 100644 --- a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts +++ b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts @@ -117,27 +117,29 @@ test('maps install, stop, start, restart, and uninstall onto one LaunchAgent ser }); }); -test('restores the previous loaded LaunchAgent when replacement bootstrap fails', async () => { - await withFixture(async ({ homeDir, cliPath, launchctl }) => { - const plistPath = resolveLaunchAgentPath(SERVICE_ID, homeDir); - const previousPlist = 'previous\n'; - await writeFile(plistPath, previousPlist, { mode: 0o600 }); - launchctl.loaded = true; - launchctl.failNextBootstrap = true; - const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, { - homeDir, - uid: UID, - runLaunchctl: launchctl.run, - isProcessAlive: () => false, - }); +test('restores the previous loaded LaunchAgent when deployment bootstrap fails', async () => { + for (const action of ['install', 'replace'] as const) { + await withFixture(async ({ homeDir, cliPath, launchctl }) => { + const plistPath = resolveLaunchAgentPath(SERVICE_ID, homeDir); + const previousPlist = 'previous\n'; + await writeFile(plistPath, previousPlist, { mode: 0o600 }); + launchctl.loaded = true; + launchctl.failNextBootstrap = true; + const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, { + homeDir, + uid: UID, + runLaunchctl: launchctl.run, + isProcessAlive: () => false, + }); - await assert.rejects( - backend.install(fixtureConfig(process.execPath, cliPath, join(homeDir, 'state'))), - /Starting the Runtime Host LaunchAgent failed/u, - ); - assert.equal(await readFile(plistPath, 'utf8'), previousPlist); - assert.equal(launchctl.loaded, true); - }); + await assert.rejects( + backend[action](fixtureConfig(process.execPath, cliPath, join(homeDir, 'state'))), + /Starting the Runtime Host LaunchAgent failed/u, + ); + assert.equal(await readFile(plistPath, 'utf8'), previousPlist); + assert.equal(launchctl.loaded, true); + }); + } }); interface FakeLaunchctl { diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index f5ab1e8c59..943f51212e 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -1231,9 +1231,24 @@ describe('managed Runtime Host service', () => { /Starting the Runtime Host service failed/u, ); assert.match(await readFile(unitPath, 'utf8'), /--websocket-port" "41001"/u); + + const replacementBackend = backend(); + await replacementBackend.stop(); + systemd.failNext('restart'); + assert.ok(first.service.config); + const replacementConfig = { + ...first.service.config, + websocket: { ...first.service.config.websocket, port: 41_004 }, + }; + await assert.rejects( + replacementBackend.replace(replacementConfig), + /Starting the Runtime Host service failed/u, + ); + assert.match(await readFile(unitPath, 'utf8'), /--websocket-port" "41001"/u); + assert.equal((await replacementBackend.status()).state, 'stopped'); }); - it('keeps the selected package configured when replacement readiness is unknown', async (t) => { + it('distinguishes backend replacement failure from unknown target readiness', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-update-failure-')); t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'config'); @@ -1246,6 +1261,7 @@ describe('managed Runtime Host service', () => { await writeFile(targetCli, '#!/usr/bin/env node\n', 'utf8'); let state: 'running' | 'stopped' = 'running'; let replaceCalls = 0; + let replaceFails = true; const backend: RuntimeHostServiceBackend = { ...createReadyBackend(), status: async () => ({ @@ -1259,7 +1275,7 @@ describe('managed Runtime Host service', () => { }), replace: async () => { replaceCalls += 1; - throw new Error('replacement failed after launch'); + if (replaceFails) throw new Error('replacement was not committed'); }, stop: async () => { state = 'stopped'; @@ -1287,10 +1303,24 @@ describe('managed Runtime Host service', () => { error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete', ); assert.equal(replaceCalls, 1); - const config = JSON.parse( + const restored = JSON.parse( + await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'), + ) as RuntimeHostManagedServiceConfig; + assert.equal(restored.launch.cliPath, await realpath(previousCli)); + + replaceFails = false; + await assert.rejects( + replaceRuntimeHostManagedService({ ...common, cliPath: targetCli, expectedTarget }, backend, { + waitForReady: async () => Promise.reject(new Error('target readiness is unknown')), + }), + (error: unknown) => + error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete', + ); + assert.equal(replaceCalls, 2); + const retained = JSON.parse( await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'), ) as RuntimeHostManagedServiceConfig; - assert.equal(config.launch.cliPath, await realpath(targetCli)); + assert.equal(retained.launch.cliPath, await realpath(targetCli)); }); it('updates through the current operator and preserves exact update outcomes', async () => { diff --git a/packages/cli/src/runtime-host-launch-agent-service.ts b/packages/cli/src/runtime-host-launch-agent-service.ts index edb4156d25..f1414efd99 100644 --- a/packages/cli/src/runtime-host-launch-agent-service.ts +++ b/packages/cli/src/runtime-host-launch-agent-service.ts @@ -124,7 +124,12 @@ export function createLaunchAgentRuntimeHostService( }, replace: async (config) => { await validateRuntimeHostServiceLaunch(config); - await applyLaunchAgentDeployment(context, config); + const previous = await captureLaunchAgentDeployment(context); + try { + await applyLaunchAgentDeployment(context, config); + } catch (error) { + await restoreFailedLaunchAgentDeployment(previous, context, error, 'update_incomplete'); + } }, verifyDeployment: async (config) => { await validateRuntimeHostServiceLaunch(config); @@ -302,12 +307,15 @@ async function restoreFailedLaunchAgentDeployment( snapshot: LaunchAgentDeploymentSnapshot, context: LaunchAgentContext, originalError: unknown, + recoveryFailureCode: + | 'service_manager_operation_failed' + | 'update_incomplete' = 'service_manager_operation_failed', ): Promise { try { await restoreLaunchAgentDeployment(snapshot, context); } catch (rollbackError) { throw new RuntimeHostServiceManagerError( - 'service_manager_operation_failed', + recoveryFailureCode, 'Updating the Runtime Host LaunchAgent failed and the previous deployment could not be restored', { cause: new AggregateError([originalError, rollbackError]) }, ); diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index d6b73d196a..cdf793d4e1 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -94,6 +94,7 @@ export interface RuntimeHostServiceBackendStatus { export interface RuntimeHostServiceBackend { preflightInstall(): Promise; install(config: RuntimeHostManagedServiceConfig): Promise; + /** A rejected replacement must restore the previous deployment or report update_incomplete. */ replace(config: RuntimeHostManagedServiceConfig): Promise; verifyDeployment(config: RuntimeHostManagedServiceConfig): Promise; status(): Promise; @@ -549,12 +550,38 @@ async function replaceRuntimeHostManagedServiceLocked( } try { await backend.replace(config); + } catch (error) { + if (error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete') { + await backend.stop().catch(() => undefined); + throw error; + } + try { + await writeRuntimeHostServiceFile( + configPath, + `${JSON.stringify(service.config, null, 2)}\n`, + 0o600, + ); + } catch (restoreError) { + await backend.stop().catch(() => undefined); + throw new RuntimeHostServiceManagerError( + 'update_incomplete', + 'Replacing the Runtime Host service failed and its previous configuration could not be restored', + { cause: new AggregateError([error, restoreError]) }, + ); + } + throw new RuntimeHostServiceManagerError( + 'update_incomplete', + 'Replacing the Runtime Host service failed; the previous deployment was restored and remains stopped', + { cause: error }, + ); + } + try { await deps.waitForReady(config, backend); } catch (error) { await backend.stop().catch(() => undefined); throw new RuntimeHostServiceManagerError( 'update_incomplete', - 'The replacement Runtime Host did not become ready; the previous deployment was retained but was not restarted because its storage compatibility is unknown', + 'The replacement Runtime Host did not become ready; the selected deployment was retained but stopped because rolling back across an unknown storage boundary is unsafe', { cause: error }, ); } diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts index 81b92d4f17..564eec8c4e 100644 --- a/packages/cli/src/runtime-host-systemd-service.ts +++ b/packages/cli/src/runtime-host-systemd-service.ts @@ -115,7 +115,12 @@ export function createSystemdUserRuntimeHostService( }, replace: async (config) => { await validateRuntimeHostServiceLaunch(config); - await applySystemdDeployment(context, config); + const previous = await captureSystemdDeployment(context.unitPath, readStatus); + try { + await applySystemdDeployment(context, config); + } catch (error) { + await restoreFailedSystemdDeployment(previous, context, error, 'update_incomplete'); + } }, verifyDeployment: async (config) => { await validateRuntimeHostServiceLaunch(config); @@ -292,12 +297,15 @@ async function restoreFailedSystemdDeployment( snapshot: SystemdDeploymentSnapshot, context: SystemdUnitContext, originalError: unknown, + recoveryFailureCode: + | 'service_manager_operation_failed' + | 'update_incomplete' = 'service_manager_operation_failed', ): Promise { try { await restoreSystemdDeployment(snapshot, context); } catch (rollbackError) { throw new RuntimeHostServiceManagerError( - 'service_manager_operation_failed', + recoveryFailureCode, 'Updating the Runtime Host service failed and the previous systemd deployment could not be restored', { cause: new AggregateError([originalError, rollbackError]) }, ); From 50a0b5bb2332b99ef3be5784579129d21241b950 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 08:56:26 +0800 Subject: [PATCH 8/8] fix(runtime-host): preserve update recovery authority Reuse an already-selected current deployment without package acquisition, and let explicitly authorized repair stop an unhealthy Host when its operator is unavailable. Preserve truthful failure reporting when a replacement cannot be stopped after readiness fails. Generated-by: Codex --- .../runtime-host-selected-update.test.ts | 10 ++-- .../runtime-host-service-manager.test.ts | 26 +++++++++++ .../cli/src/runtime-host-service-manager.ts | 10 +++- .../cli/src/runtime-host-update-command.ts | 46 +++++++++++++------ 4 files changed, 73 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index ed048c3b7a..8ce5ac0194 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -113,19 +113,19 @@ describe('managed Runtime Host selected update', () => { it('lets the exact transaction decide whether a current candidate needs repair', async () => { const selection = updateSelection({ kind: 'current' }); - let updates = 0; + let updateInput: RuntimeHostUpdateCliOptions | undefined; assert.equal( await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, { resolveSelection: async () => selection, - withPackage: async (_candidate, use) => use('/verified/package'), - update: async () => { - updates += 1; + withPackage: async () => assert.fail('the current deployment must not be downloaded'), + update: async (input) => { + updateInput = input; return 0; }, }), 0, ); - assert.equal(updates, 1); + assert.equal(updateInput?.sourcePackageRoot, '/managed/versions/2.0.0'); }); it('keeps non-admitted candidates outside package acquisition and mutation', async () => { diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 943f51212e..244bdb7c93 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -1262,6 +1262,7 @@ describe('managed Runtime Host service', () => { let state: 'running' | 'stopped' = 'running'; let replaceCalls = 0; let replaceFails = true; + let stopFails = false; const backend: RuntimeHostServiceBackend = { ...createReadyBackend(), status: async () => ({ @@ -1276,8 +1277,10 @@ describe('managed Runtime Host service', () => { replace: async () => { replaceCalls += 1; if (replaceFails) throw new Error('replacement was not committed'); + state = 'running'; }, stop: async () => { + if (stopFails) throw new Error('replacement could not be stopped'); state = 'stopped'; }, }; @@ -1321,6 +1324,19 @@ describe('managed Runtime Host service', () => { await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'), ) as RuntimeHostManagedServiceConfig; assert.equal(retained.launch.cliPath, await realpath(targetCli)); + + stopFails = true; + await assert.rejects( + replaceRuntimeHostManagedService({ ...common, cliPath: targetCli, expectedTarget }, backend, { + waitForReady: async () => Promise.reject(new Error('target readiness is unknown')), + }), + (error: unknown) => + error instanceof RuntimeHostServiceManagerError && + error.code === 'update_incomplete' && + error.cause instanceof AggregateError && + /could not be stopped/u.test(error.message), + ); + assert.equal(state, 'running'); }); it('updates through the current operator and preserves exact update outcomes', async () => { @@ -1370,6 +1386,7 @@ describe('managed Runtime Host service', () => { let readyFailure = false; let operatorSupportsProcessLifetimeLock = false; let legacyLeaseCalls = 0; + let operatorStatusFailure = false; let operatorFailure: Extract | undefined; let replaceFailure = false; let cleanupFailure = false; @@ -1448,6 +1465,7 @@ describe('managed Runtime Host service', () => { const action = args[0]; assert.ok(action === 'status' || action === 'retire'); if (action === 'status') { + if (operatorStatusFailure) throw new Error('The active operator is unavailable'); assert.equal( invocation?.capabilityRequest, RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, @@ -1662,6 +1680,14 @@ describe('managed Runtime Host service', () => { 'repaired', ); + order.length = 0; + statusReads = 0; + output = ''; + operatorStatusFailure = true; + assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0); + assert.deepEqual(order, ['stop', 'activate', 'replace', 'cleanup']); + operatorStatusFailure = false; + order.length = 0; statusReads = 0; output = ''; diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index cdf793d4e1..eff0eae9fd 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -578,7 +578,15 @@ async function replaceRuntimeHostManagedServiceLocked( try { await deps.waitForReady(config, backend); } catch (error) { - await backend.stop().catch(() => undefined); + try { + await backend.stop(); + } catch (stopError) { + throw new RuntimeHostServiceManagerError( + 'update_incomplete', + 'The replacement Runtime Host did not become ready and could not be stopped; inspect the service state before retrying', + { cause: new AggregateError([error, stopError]) }, + ); + } throw new RuntimeHostServiceManagerError( 'update_incomplete', 'The replacement Runtime Host did not become ready; the selected deployment was retained but stopped because rolling back across an unknown storage boundary is unsafe', diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index f21a804663..aca22ad34c 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -18,7 +18,7 @@ */ import { spawn } from 'node:child_process'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { decodeRuntimeHostServiceManagementFrame, @@ -243,15 +243,22 @@ export async function runManagedRuntimeHostUpdateCli( } const currentOperatorPath = join(serviceConfig.managedDeploymentRoot, 'operator'); - const currentOperatorUsesProcessLifetimeLock = status.service.active - ? operatorUsesProcessLifetimeLock( + let currentOperatorUsesProcessLifetimeLock = false; + let currentOperatorUnavailable = false; + if (status.service.active) { + try { + currentOperatorUsesProcessLifetimeLock = operatorUsesProcessLifetimeLock( await deps.runOperator( currentOperatorPath, ['status', '--framed', ...expectedTargetArgs(options.expectedTarget)], { capabilityRequest: RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY }, ), - ) - : false; + ); + } catch (error) { + if (!activeTargetNeedsRepair) throw error; + currentOperatorUnavailable = true; + } + } emit(progress('staging', currentVersion, options.version)); deployment = await deps.withLifecycleLock(options.clientDataRoot, () => @@ -294,12 +301,22 @@ export async function runManagedRuntimeHostUpdateCli( : deps.withLegacyOperatorLeases(options.clientDataRoot, (inheritedFds) => deps.runOperator(currentOperatorPath, args, { inheritedFds }), ); - let retirement = await runCurrentOperator([ - 'retire', - '--framed', - ...expectedTargetArgs(options.expectedTarget), - ...(options.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), - ]); + let retirement: RuntimeHostServiceManagementFrame = currentOperatorUnavailable + ? { + schemaVersion: 1, + kind: 'error', + action: 'retire', + error: { + code: 'retirement_failed', + message: 'The active Runtime Host operator is unavailable', + }, + } + : await runCurrentOperator([ + 'retire', + '--framed', + ...expectedTargetArgs(options.expectedTarget), + ...(options.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), + ]); if ( activeTargetNeedsRepair && retirement.kind === 'error' && @@ -518,7 +535,7 @@ export async function runManagedRuntimeHostSelectedUpdateCli( return 1; } - return await deps.withPackage(selection.candidate, async (packageRoot) => { + const apply = async (packageRoot: string) => { const { selector: _selector, ...updateOptions } = options; return await deps.update({ ...updateOptions, @@ -532,7 +549,10 @@ export async function runManagedRuntimeHostSelectedUpdateCli( }, }, }); - }); + }; + return selection.outcome.kind === 'current' + ? await apply(dirname(dirname(selection.currentCliPath))) + : await deps.withPackage(selection.candidate, apply); } catch (error) { const code = error instanceof RuntimeHostUpdateDiscoveryError ||