From d3f7ecd2f60cd2c24478b705ff038931242336b2 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Thu, 27 Aug 2026 15:17:12 +0800 Subject: [PATCH 01/11] feat(runtime-host): fence managed deployment launches Introduce the canonical managed deployment contract and a durable State Root lifecycle fence. Require matching operator claims before managed candidate or service launches can acquire ownership. Part of #3984 Generated-by: OpenAI Codex --- .../src/__tests__/candidate-cli.test.ts | 61 ++ .../src/__tests__/host-kernel.test.ts | 81 +++ .../src/__tests__/managed-deployment.test.ts | 238 +++++++ .../src/__tests__/startup-error.test.ts | 14 + packages/runtime-host/src/candidate-cli.ts | 52 ++ .../src/client/connect-or-spawn.ts | 26 +- packages/runtime-host/src/client/launcher.ts | 10 + .../runtime-host/src/client/startup-error.ts | 30 +- packages/runtime-host/src/operator/index.ts | 23 + .../src/operator/managed-deployment.ts | 593 ++++++++++++++++++ packages/runtime-host/src/server/candidate.ts | 6 + .../src/server/execution-service.ts | 6 + 12 files changed, 1138 insertions(+), 2 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/managed-deployment.test.ts create mode 100644 packages/runtime-host/src/operator/managed-deployment.ts diff --git a/packages/runtime-host/src/__tests__/candidate-cli.test.ts b/packages/runtime-host/src/__tests__/candidate-cli.test.ts index 4499e37ee5..51ff39ec9b 100644 --- a/packages/runtime-host/src/__tests__/candidate-cli.test.ts +++ b/packages/runtime-host/src/__tests__/candidate-cli.test.ts @@ -23,6 +23,7 @@ import { parseInteractiveRuntimeHostCandidateArguments } from '../candidate-cli. const ROOT_ID = 'a'.repeat(64); const STARTUP_ATTEMPT_ID = '00000000-0000-4000-8000-000000000001'; +const DEPLOYMENT_ID = '00000000-0000-4000-8000-000000000002'; test('parses the production candidate flags', () => { const parsed = parseInteractiveRuntimeHostCandidateArguments([ @@ -41,6 +42,66 @@ test('parses the production candidate flags', () => { assert.equal(parsed.idleGraceMs, 10_000); }); +test('parses a complete managed on-demand launch claim', () => { + const parsed = parseInteractiveRuntimeHostCandidateArguments([ + '--root', + '/tmp/workspace', + '--expected-root-id', + ROOT_ID, + '--startup-attempt-id', + STARTUP_ATTEMPT_ID, + '--managed-deployment-id', + DEPLOYMENT_ID, + '--managed-config-revision', + '7', + '--managed-lifecycle-mode', + 'on_demand', + ]); + + assert.deepEqual(parsed.managedLaunchClaim, { + deploymentId: DEPLOYMENT_ID, + configRevision: 7, + lifecycle: { mode: 'on_demand' }, + }); +}); + +test('rejects partial or contradictory managed launch claims', () => { + assert.throws( + () => + parseInteractiveRuntimeHostCandidateArguments([ + '--root', + '/tmp/workspace', + '--expected-root-id', + ROOT_ID, + '--startup-attempt-id', + STARTUP_ATTEMPT_ID, + '--managed-deployment-id', + DEPLOYMENT_ID, + ]), + /complete managed launch claim/u, + ); + assert.throws( + () => + parseInteractiveRuntimeHostCandidateArguments([ + '--root', + '/tmp/workspace', + '--expected-root-id', + ROOT_ID, + '--startup-attempt-id', + STARTUP_ATTEMPT_ID, + '--managed-deployment-id', + DEPLOYMENT_ID, + '--managed-config-revision', + '7', + '--managed-lifecycle-mode', + 'on_demand', + '--managed-provider', + 'systemd_user', + ]), + /cannot declare a supervisor provider/u, + ); +}); + // The Desktop E2E composition is selected by its own entry module, not by a // flag on the production CLI — so `--desktop-e2e` is simply unknown here. test('rejects the retired desktop E2E flag as an unknown argument', () => { diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 6ea7a51589..1e6fa03035 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -59,6 +59,7 @@ import { readCandidateStartupDiagnostic, writeCandidateStartupDiagnostic, } from '../control/startup-diagnostic.js'; +import { claimRuntimeHostLifecycleFence } from '../operator/managed-deployment.js'; import { removePosixEndpointDirectories } from './fixtures/endpoint-hygiene.js'; import { decodeHostFrame, @@ -157,6 +158,86 @@ function diagnosticRegistration(state: 'ready' | 'draining') { } describe('non-serving Runtime Host kernel', () => { + test('a managed State Root refuses an ordinary candidate launch before election', async () => { + await withHostPaths(async (paths) => { + const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); + await claimRuntimeHostLifecycleFence(capability, { + deploymentId: '00000000-0000-4000-8000-000000000001', + configRevision: 1, + lifecycle: { mode: 'on_demand' }, + }); + let launches = 0; + + const result = await connectOrSpawnRuntimeHostWithDependencies( + { + rootPath: paths.root, + protocol: CURRENT_PROTOCOL, + compositionId: KERNEL_COMPOSITION.descriptor.id, + candidateEntrypoint: KERNEL_CANDIDATE_ENTRYPOINT, + electionDeadlineMs: 1_000, + }, + { + random: () => 0.5, + connectHost: async () => ({ + kind: 'unavailable', + reason: 'not_registered', + endpointConnected: false, + }), + launchCandidate: () => { + launches += 1; + throw new Error('managed root must not launch without its operator claim'); + }, + }, + ); + + assert.deepEqual(result, { + kind: 'failed', + reason: 'managed_root_requires_operator', + }); + assert.equal(launches, 0); + }); + }); + + test('a matching managed claim reaches the existing candidate election', async () => { + await withHostPaths(async (paths) => { + const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); + const claim = { + deploymentId: '00000000-0000-4000-8000-000000000001', + configRevision: 1, + lifecycle: { mode: 'on_demand' as const }, + }; + await claimRuntimeHostLifecycleFence(capability, claim); + let launches = 0; + + const result = await connectOrSpawnRuntimeHostWithDependencies( + { + rootPath: paths.root, + protocol: CURRENT_PROTOCOL, + compositionId: KERNEL_COMPOSITION.descriptor.id, + candidateEntrypoint: KERNEL_CANDIDATE_ENTRYPOINT, + managedLaunchClaim: claim, + electionDeadlineMs: 100, + }, + { + random: () => 0.5, + connectHost: async () => ({ + kind: 'unavailable', + reason: 'not_registered', + endpointConnected: false, + }), + launchCandidate: () => { + launches += 1; + return { spawned: new Promise(() => undefined) }; + }, + }, + ); + + assert.equal(result.kind, 'failed'); + if (result.kind === 'failed') assert.equal(result.reason, 'startup_timeout'); + assert.equal(launches, 1); + }); + }); + test('reports a recovery failure when the election produces no ready Host', async () => { await withHostPaths(async (paths) => { const result = await connectOrSpawnRuntimeHostWithDependencies( diff --git a/packages/runtime-host/src/__tests__/managed-deployment.test.ts b/packages/runtime-host/src/__tests__/managed-deployment.test.ts new file mode 100644 index 0000000000..9c93b034b5 --- /dev/null +++ b/packages/runtime-host/src/__tests__/managed-deployment.test.ts @@ -0,0 +1,238 @@ +/* + * 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 { lstat, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { resolveStorageRoot } from '@maka/storage/root-authority'; +import { + RuntimeHostManagedDeploymentError, + assertRuntimeHostManagedLaunchAuthorized, + claimRuntimeHostLifecycleFence, + decodeRuntimeHostManagedDeploymentConfig, + readRuntimeHostLifecycleFence, + readRuntimeHostManagedDeploymentConfig, + releaseRuntimeHostLifecycleFence, + resolveRuntimeHostManagedDeploymentConfigPath, + runtimeHostManagedLaunchRejection, + writeRuntimeHostManagedDeploymentConfig, + type RuntimeHostManagedDeploymentConfig, + type RuntimeHostManagedLaunchClaim, +} from '../operator/managed-deployment.js'; + +const DEPLOYMENT_ID = '00000000-0000-4000-8000-000000000001'; +const OTHER_DEPLOYMENT_ID = '00000000-0000-4000-8000-000000000002'; +const ROOT_ID = 'a'.repeat(64); +const PACKAGE_INTEGRITY = 'sha512-' + Buffer.alloc(64, 1).toString('base64'); +const ON_DEMAND_CLAIM: RuntimeHostManagedLaunchClaim = { + deploymentId: DEPLOYMENT_ID, + configRevision: 1, + lifecycle: { mode: 'on_demand' }, +}; + +function config( + overrides: Partial = {}, +): RuntimeHostManagedDeploymentConfig { + return { + schemaVersion: 1, + deploymentId: DEPLOYMENT_ID, + configRevision: 1, + deploymentRoot: '/opt/maka/runtime-host', + root: { path: '/srv/maka/state', id: ROOT_ID }, + projectDirectoryRoots: [{ label: 'projects', path: '/srv/projects' }], + launch: { + kind: 'exact_package', + nodePath: '/usr/bin/node', + cliPath: '/opt/maka/runtime-host/versions/1.2.3/cli.js', + package: { + kind: 'npm_registry', + version: '1.2.3', + integrity: PACKAGE_INTEGRITY, + }, + }, + listeners: { + localIpc: true, + websocket: { + host: '127.0.0.1', + port: 43_210, + path: '/runtime-host', + }, + }, + lifecycle: { mode: 'on_demand', availability: 'activation' }, + reconciliation: { + policy: 'automatic', + trigger: { kind: 'activation' }, + }, + ...overrides, + }; +} + +async function root(t: test.TestContext) { + const path = await mkdtemp(join(tmpdir(), 'maka-managed-deployment-')); + t.after(() => rm(path, { recursive: true, force: true })); + return resolveStorageRoot({ path, kind: 'interactive' }); +} + +test('strictly decodes the canonical on-demand deployment contract', () => { + assert.deepEqual(decodeRuntimeHostManagedDeploymentConfig(config()), config()); + assert.throws( + () => + decodeRuntimeHostManagedDeploymentConfig({ + ...config(), + credential: 'must-not-be-persisted', + }), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', + ); +}); + +test('rejects lifecycle and reconciliation combinations that cannot be honored', () => { + assert.throws( + () => + decodeRuntimeHostManagedDeploymentConfig( + config({ + reconciliation: { + policy: 'automatic', + trigger: { kind: 'scheduled', provider: 'systemd_timer' }, + }, + }), + ), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', + ); + assert.throws( + () => + decodeRuntimeHostManagedDeploymentConfig( + config({ + lifecycle: { + mode: 'supervised', + provider: 'launch_agent', + availability: 'session', + }, + reconciliation: { + policy: 'automatic', + trigger: { kind: 'scheduled', provider: 'systemd_timer' }, + }, + }), + ), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', + ); +}); + +test('writes and reads a bounded private canonical deployment file', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-managed-config-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + const path = resolveRuntimeHostManagedDeploymentConfigPath(clientDataRoot); + + await writeRuntimeHostManagedDeploymentConfig(path, config()); + + assert.deepEqual(await readRuntimeHostManagedDeploymentConfig(path), config()); + if (process.platform !== 'win32') { + assert.equal((await lstat(path)).mode & 0o777, 0o600); + } +}); + +test('rejects oversized deployment documents before parsing', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-managed-config-large-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + const path = resolveRuntimeHostManagedDeploymentConfigPath(clientDataRoot); + await writeFile(path, 'x'.repeat(64 * 1024 + 1)); + + await assert.rejects( + readRuntimeHostManagedDeploymentConfig(path), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', + ); +}); + +test('claims one idempotent lifecycle owner and refuses a competing deployment', async (t) => { + const capability = await root(t); + const first = await claimRuntimeHostLifecycleFence(capability, ON_DEMAND_CLAIM); + const retried = await claimRuntimeHostLifecycleFence(capability, ON_DEMAND_CLAIM); + + assert.equal(first.kind, 'applied'); + assert.equal(retried.kind, 'unchanged'); + assert.deepEqual(retried.fence, first.fence); + await assert.rejects( + claimRuntimeHostLifecycleFence(capability, { + ...ON_DEMAND_CLAIM, + deploymentId: OTHER_DEPLOYMENT_ID, + }), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && error.code === 'lifecycle_owner_exists', + ); +}); + +test('releases only the exact observed lifecycle fence revision', async (t) => { + const capability = await root(t); + const claimed = await claimRuntimeHostLifecycleFence(capability, ON_DEMAND_CLAIM); + + await assert.rejects( + releaseRuntimeHostLifecycleFence(capability, { + revision: OTHER_DEPLOYMENT_ID, + deploymentId: DEPLOYMENT_ID, + configRevision: 1, + }), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && + error.code === 'lifecycle_owner_changed', + ); + assert.equal( + await releaseRuntimeHostLifecycleFence(capability, { + revision: claimed.fence.revision, + deploymentId: DEPLOYMENT_ID, + configRevision: 1, + }), + 'released', + ); + assert.equal(await readRuntimeHostLifecycleFence(capability), undefined); +}); + +test('maps lifecycle fence states and claims to fail-closed launch decisions', async (t) => { + const capability = await root(t); + assert.equal(runtimeHostManagedLaunchRejection(undefined, undefined), undefined); + assert.equal( + runtimeHostManagedLaunchRejection(undefined, ON_DEMAND_CLAIM), + 'deployment_fence_missing', + ); + + const claimed = await claimRuntimeHostLifecycleFence(capability, ON_DEMAND_CLAIM); + assert.equal( + runtimeHostManagedLaunchRejection(claimed.fence, undefined), + 'managed_root_requires_operator', + ); + assert.equal( + runtimeHostManagedLaunchRejection(claimed.fence, { + ...ON_DEMAND_CLAIM, + configRevision: 2, + }), + 'deployment_fence_mismatch', + ); + assert.equal(runtimeHostManagedLaunchRejection(claimed.fence, ON_DEMAND_CLAIM), undefined); + await assert.rejects( + assertRuntimeHostManagedLaunchAuthorized(capability, undefined), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && + error.code === 'managed_root_requires_operator', + ); + await assert.doesNotReject(assertRuntimeHostManagedLaunchAuthorized(capability, ON_DEMAND_CLAIM)); +}); diff --git a/packages/runtime-host/src/__tests__/startup-error.test.ts b/packages/runtime-host/src/__tests__/startup-error.test.ts index 2c2ea6d484..44d6b0b7da 100644 --- a/packages/runtime-host/src/__tests__/startup-error.test.ts +++ b/packages/runtime-host/src/__tests__/startup-error.test.ts @@ -36,6 +36,20 @@ test('presents migration blockers with a permanent previous-release recovery pat assert.match(error.message, /OPERATIONAL_STATE_MIGRATION_BLOCKED/u); }); +test('presents a managed root bypass as an operator-required permanent error', () => { + const error = runtimeHostStartupError('managed_root_requires_operator'); + assert.ok(error instanceof RuntimeHostPermanentReconnectError); + assert.match(error.message, /configured Host profile/u); + assert.match(error.message, /MANAGED_ROOT_REQUIRES_OPERATOR/u); +}); + +test('presents an uncertain lifecycle owner as requiring repair', () => { + const error = runtimeHostStartupError('deployment_needs_repair'); + assert.ok(error instanceof RuntimeHostPermanentReconnectError); + assert.match(error.message, /repair/u); + assert.match(error.message, /DEPLOYMENT_NEEDS_REPAIR/u); +}); + test('keeps an unresponsive Host retryable and includes bounded diagnostics', () => { const error = runtimeHostStartupError('host_unresponsive', { deadlineMs: 45_000, diff --git a/packages/runtime-host/src/candidate-cli.ts b/packages/runtime-host/src/candidate-cli.ts index c6c5c4b01c..31232e7cc8 100644 --- a/packages/runtime-host/src/candidate-cli.ts +++ b/packages/runtime-host/src/candidate-cli.ts @@ -19,6 +19,10 @@ import type { InteractiveRuntimeHostCandidateOptions } from './server/candidate.js'; import { isCandidateStartupAttemptId } from './candidate-startup-failure.js'; +import { + decodeRuntimeHostManagedLaunchClaim, + type RuntimeHostManagedLaunchClaim, +} from './operator/managed-deployment.js'; export interface ParsedInteractiveRuntimeHostCandidateArguments extends InteractiveRuntimeHostCandidateOptions { @@ -36,6 +40,10 @@ export function parseInteractiveRuntimeHostCandidateArguments( 'idle-grace-ms', 'handshake-timeout-ms', 'generation', + 'managed-deployment-id', + 'managed-config-revision', + 'managed-lifecycle-mode', + 'managed-provider', ]); const values = new Map(); for (let index = 0; index < args.length; index += 2) { @@ -60,6 +68,7 @@ export function parseInteractiveRuntimeHostCandidateArguments( if (!isCandidateStartupAttemptId(startupAttemptId)) { throw new Error('Runtime Host candidate requires a valid --startup-attempt-id'); } + const managedLaunchClaim = readManagedLaunchClaim(values); return { rootPath, expectedRootId, @@ -68,9 +77,52 @@ export function parseInteractiveRuntimeHostCandidateArguments( idleGraceMs: readOptionalInteger(values, 'idle-grace-ms'), handshakeTimeoutMs: readOptionalInteger(values, 'handshake-timeout-ms'), ...(values.has('generation') ? { generation: readGeneration(values) } : {}), + ...(managedLaunchClaim === undefined ? {} : { managedLaunchClaim }), }; } +function readManagedLaunchClaim( + values: ReadonlyMap, +): RuntimeHostManagedLaunchClaim | undefined { + const deploymentId = values.get('managed-deployment-id'); + const rawRevision = values.get('managed-config-revision'); + const lifecycleMode = values.get('managed-lifecycle-mode'); + const provider = values.get('managed-provider'); + if ( + deploymentId === undefined && + rawRevision === undefined && + lifecycleMode === undefined && + provider === undefined + ) { + return undefined; + } + if (deploymentId === undefined || rawRevision === undefined || lifecycleMode === undefined) { + throw new Error('Runtime Host candidate requires a complete managed launch claim'); + } + const configRevision = Number(rawRevision); + if (!Number.isSafeInteger(configRevision) || configRevision <= 0) { + throw new Error('Invalid --managed-config-revision'); + } + if (lifecycleMode === 'on_demand') { + if (provider !== undefined) { + throw new Error('An on-demand Runtime Host candidate cannot declare a supervisor provider'); + } + return decodeRuntimeHostManagedLaunchClaim({ + deploymentId, + configRevision, + lifecycle: { mode: lifecycleMode }, + }); + } + if (lifecycleMode === 'supervised' && provider !== undefined) { + return decodeRuntimeHostManagedLaunchClaim({ + deploymentId, + configRevision, + lifecycle: { mode: lifecycleMode, provider }, + }); + } + throw new Error('Invalid --managed-lifecycle-mode'); +} + function readGeneration(values: Map): string { const value = values.get('generation'); if (!value || value.length > 128) throw new Error('Invalid --generation'); diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts index 0c54bbd0f3..46f55d2768 100644 --- a/packages/runtime-host/src/client/connect-or-spawn.ts +++ b/packages/runtime-host/src/client/connect-or-spawn.ts @@ -57,6 +57,13 @@ import { clearCandidateStartupDiagnostic, selectCandidateStartupDiagnostic, } from '../control/startup-diagnostic.js'; +import { + decodeRuntimeHostManagedLaunchClaim, + readRuntimeHostLifecycleFence, + runtimeHostManagedLaunchRejection, + type RuntimeHostManagedLaunchClaim, + type RuntimeHostManagedLaunchRejection, +} from '../operator/managed-deployment.js'; import { abortable, waitForRuntimeHostReady } from './wait-for-ready.js'; const DEFAULT_ELECTION_DEADLINE_MS = 45_000; @@ -76,6 +83,7 @@ export interface ConnectOrSpawnRuntimeHostInput { connectTimeoutMs?: number; handshakeTimeoutMs?: number; candidateEntrypoint: string | URL; + managedLaunchClaim?: RuntimeHostManagedLaunchClaim; signal?: AbortSignal; /** Candidate-exit sink forwarded to the launcher; the embedder owns the sink. */ onExit?: (details: CandidateExitDetails) => void; @@ -133,7 +141,11 @@ export type ConnectOrSpawnRuntimeHostResult = } | { kind: 'failed'; - reason: CandidateStartupFailure['reason'] | 'startup_timeout' | 'host_unresponsive'; + reason: + | CandidateStartupFailure['reason'] + | RuntimeHostManagedLaunchRejection + | 'startup_timeout' + | 'host_unresponsive'; diagnostic?: RuntimeHostElectionDiagnostic; }; @@ -296,6 +308,10 @@ export async function connectOrSpawnRuntimeHostWithDependencies( requireHostCompositionId(input.compositionId); requireOptionalTimeout(input.connectTimeoutMs, 'connectTimeoutMs', 1); requireOptionalTimeout(input.handshakeTimeoutMs, 'handshakeTimeoutMs', 1); + const managedLaunchClaim = + input.managedLaunchClaim === undefined + ? undefined + : decodeRuntimeHostManagedLaunchClaim(input.managedLaunchClaim); input.signal?.throwIfAborted(); const clientInstanceId = requireClientInstanceId(input.clientInstanceId ?? randomUUID()); const capability = await resolveStorageRoot({ path: input.rootPath, kind: 'interactive' }); @@ -408,6 +424,13 @@ export async function connectOrSpawnRuntimeHostWithDependencies( !candidateInFlight && now >= nextCandidateAt ) { + const managedLaunchRejection = runtimeHostManagedLaunchRejection( + await readRuntimeHostLifecycleFence(capability), + managedLaunchClaim, + ); + if (managedLaunchRejection !== undefined) { + return { kind: 'failed', reason: managedLaunchRejection }; + } try { const remaining = deadline - performance.now(); if (remaining <= 0) break; @@ -417,6 +440,7 @@ export async function connectOrSpawnRuntimeHostWithDependencies( entrypoint: input.candidateEntrypoint, initialConnectionTimeoutMs: Math.ceil(remaining), ...(input.generation === undefined ? {} : { generation: input.generation }), + ...(managedLaunchClaim === undefined ? {} : { managedLaunchClaim }), ...(input.onExit === undefined ? {} : { onExit: input.onExit }), }); candidateLaunches.add(launch); diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index 3fa18c8d72..1083512a28 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -25,6 +25,7 @@ import { candidateStartupFailureForExitCode, type CandidateStartupFailureReport, } from '../candidate-startup-failure.js'; +import type { RuntimeHostManagedLaunchClaim } from '../operator/managed-deployment.js'; import { RUNTIME_HOST_STDERR_PIPE_ENV } from '../process-diagnostics.js'; const CANDIDATE_STDERR_MAX_BYTES = 4 * 1024; @@ -42,6 +43,7 @@ export interface DetachedCandidateInput { initialConnectionTimeoutMs?: number; idleGraceMs?: number; handshakeTimeoutMs?: number; + managedLaunchClaim?: RuntimeHostManagedLaunchClaim; executable?: string; entrypoint: string | URL; env?: NodeJS.ProcessEnv; @@ -136,6 +138,14 @@ function spawnCandidate( appendArgument(args, '--idle-grace-ms', input.idleGraceMs); appendArgument(args, '--handshake-timeout-ms', input.handshakeTimeoutMs); appendArgument(args, '--generation', input.generation); + if (input.managedLaunchClaim !== undefined) { + appendArgument(args, '--managed-deployment-id', input.managedLaunchClaim.deploymentId); + appendArgument(args, '--managed-config-revision', input.managedLaunchClaim.configRevision); + appendArgument(args, '--managed-lifecycle-mode', input.managedLaunchClaim.lifecycle.mode); + if (input.managedLaunchClaim.lifecycle.mode === 'supervised') { + appendArgument(args, '--managed-provider', input.managedLaunchClaim.lifecycle.provider); + } + } // spawn() commits the side effect synchronously; spawned only reports that commit's outcome. const child = spawn(executable, args, { diff --git a/packages/runtime-host/src/client/startup-error.ts b/packages/runtime-host/src/client/startup-error.ts index 824606da52..d1ad0408bd 100644 --- a/packages/runtime-host/src/client/startup-error.ts +++ b/packages/runtime-host/src/client/startup-error.ts @@ -18,11 +18,13 @@ */ import type { CandidateStartupFailureReason } from '../candidate-startup-failure.js'; +import type { RuntimeHostManagedLaunchRejection } from '../operator/managed-deployment.js'; import type { RuntimeHostElectionDiagnostic } from './connect-or-spawn.js'; import { RuntimeHostPermanentReconnectError } from './reconnect-lifecycle.js'; export type RuntimeHostStartupFailureReason = | CandidateStartupFailureReason + | RuntimeHostManagedLaunchRejection | 'composition_mismatch' | 'startup_timeout' | 'host_unresponsive'; @@ -34,7 +36,8 @@ export class RuntimeHostStartupError extends RuntimeHostPermanentReconnectError readonly reason: | 'stored_data_incompatible' | 'operational_state_migration_blocked' - | 'composition_mismatch', + | 'composition_mismatch' + | RuntimeHostManagedLaunchRejection, message: string, ) { super(message); @@ -69,6 +72,31 @@ export function runtimeHostStartupError( reason, 'This workspace belongs to a different Runtime Host composition. Diagnostic code: COMPOSITION_MISMATCH.', ); + case 'managed_root_requires_operator': + return new RuntimeHostStartupError( + reason, + 'This workspace is managed by a Runtime Host operator. Activate it through the configured Host profile. Diagnostic code: MANAGED_ROOT_REQUIRES_OPERATOR.', + ); + case 'deployment_fence_missing': + return new RuntimeHostStartupError( + reason, + 'The managed Runtime Host deployment is missing its State Root lifecycle fence. Repair the deployment before connecting. Diagnostic code: DEPLOYMENT_FENCE_MISSING.', + ); + case 'deployment_fence_mismatch': + return new RuntimeHostStartupError( + reason, + 'The Runtime Host operator does not match the State Root lifecycle owner. Repair or explicitly migrate the deployment. Diagnostic code: DEPLOYMENT_FENCE_MISMATCH.', + ); + case 'deployment_transition_in_progress': + return new RuntimeHostStartupError( + reason, + 'The Runtime Host deployment is changing lifecycle owner. Retry after the operation completes. Diagnostic code: DEPLOYMENT_TRANSITION_IN_PROGRESS.', + ); + case 'deployment_needs_repair': + return new RuntimeHostStartupError( + reason, + 'The Runtime Host deployment could not prove a safe lifecycle owner. Run deployment repair before connecting. Diagnostic code: DEPLOYMENT_NEEDS_REPAIR.', + ); case 'startup_timeout': return new Error( `No Runtime Host became ready before the startup deadline elapsed${electionDiagnosticSuffix(diagnostic)}. Retry; if this workspace needs longer to open (large workspaces can after an upgrade), set MAKA_RUNTIME_HOST_ELECTION_DEADLINE_MS to allow more time.`, diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index a22c04bcfa..99bb093c60 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -103,3 +103,26 @@ export { type LocalHostProcessDeploymentHandoffResult, type LocalHostHandoffActiveWorkPolicy, } from './local-process-deployment-handoff.js'; +export { + RUNTIME_HOST_LIFECYCLE_FENCE_FILE, + RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE, + RuntimeHostManagedDeploymentError, + assertRuntimeHostManagedLaunchAuthorized, + claimRuntimeHostLifecycleFence, + decodeRuntimeHostLifecycleFence, + decodeRuntimeHostManagedDeploymentConfig, + decodeRuntimeHostManagedLaunchClaim, + readRuntimeHostLifecycleFence, + readRuntimeHostManagedDeploymentConfig, + releaseRuntimeHostLifecycleFence, + resolveRuntimeHostManagedDeploymentConfigPath, + runtimeHostManagedLaunchRejection, + writeRuntimeHostManagedDeploymentConfig, + type RuntimeHostActiveLifecycleFence, + type RuntimeHostLifecycleFence, + type RuntimeHostManagedDeploymentConfig, + type RuntimeHostManagedLaunchClaim, + type RuntimeHostManagedLaunchRejection, + type RuntimeHostReconciliationProvider, + type RuntimeHostSupervisorProvider, +} from './managed-deployment.js'; diff --git a/packages/runtime-host/src/operator/managed-deployment.ts b/packages/runtime-host/src/operator/managed-deployment.ts new file mode 100644 index 0000000000..303847ec9d --- /dev/null +++ b/packages/runtime-host/src/operator/managed-deployment.ts @@ -0,0 +1,593 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { chmod, lstat, open, readFile, rename, rm, unlink } from 'node:fs/promises'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { + prepareStorageRootControlDirectory, + type StorageRootCapability, +} from '@maka/storage/root-authority'; +import { withProcessLifetimeFileUpdateLock } from '@maka/storage/process-lifetime-file-update-lock'; +import { z } from 'zod'; +import { + isRuntimeHostNpmDeploymentIdentity, + type RuntimeHostDeploymentIdentity, +} from './update-package-evidence.js'; + +export const RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE = 'runtime-host-deployment.json'; +export const RUNTIME_HOST_LIFECYCLE_FENCE_FILE = 'runtime-host-managed-owner.json'; + +const SCHEMA_VERSION = 1 as const; +const MAX_DOCUMENT_BYTES = 64 * 1024; +const UPDATE_LOCK_TIMEOUT_MS = 60_000; +const ROOT_ID_PATTERN = /^[a-f0-9]{64}$/u; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +const boundedText = (maximumBytes: number) => + z + .string() + .min(1) + .refine( + (value) => + Buffer.byteLength(value, 'utf8') <= maximumBytes && !/[\u0000-\u001f\u007f]/u.test(value), + ); + +const absolutePathSchema = boundedText(4_096).refine(isAbsolute); +const deploymentIdSchema = z.string().regex(UUID_PATTERN); +const configRevisionSchema = z.number().int().positive().safe(); +const providerSchema = z.enum(['systemd_user', 'launch_agent', 'openrc_user', 'openrc_system']); +const reconciliationProviderSchema = z.enum([ + 'systemd_timer', + 'launch_agent_timer', + 'openrc_supervised_loop', +]); +const packageIdentitySchema = z.custom( + isRuntimeHostNpmDeploymentIdentity, +); + +const lifecycleSchema = z.discriminatedUnion('mode', [ + z + .object({ + mode: z.literal('on_demand'), + availability: z.literal('activation'), + }) + .strict(), + z + .object({ + mode: z.literal('supervised'), + provider: providerSchema, + availability: z.enum(['session', 'environment', 'machine']), + }) + .strict(), +]); + +const reconciliationSchema = z.discriminatedUnion('policy', [ + z.object({ policy: z.literal('manual') }).strict(), + z + .object({ + policy: z.literal('automatic'), + trigger: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('activation') }).strict(), + z + .object({ + kind: z.literal('scheduled'), + provider: reconciliationProviderSchema, + }) + .strict(), + ]), + }) + .strict(), +]); + +const managedDeploymentConfigSchema = z + .object({ + schemaVersion: z.literal(SCHEMA_VERSION), + deploymentId: deploymentIdSchema, + configRevision: configRevisionSchema, + deploymentRoot: absolutePathSchema, + root: z + .object({ + path: absolutePathSchema, + id: z.string().regex(ROOT_ID_PATTERN), + }) + .strict(), + projectDirectoryRoots: z + .array( + z + .object({ + label: boundedText(256), + path: absolutePathSchema, + }) + .strict(), + ) + .max(128), + launch: z + .object({ + kind: z.literal('exact_package'), + nodePath: absolutePathSchema, + cliPath: absolutePathSchema, + package: packageIdentitySchema, + }) + .strict(), + listeners: z + .object({ + localIpc: z.literal(true), + websocket: z + .object({ + host: z.literal('127.0.0.1'), + port: z.number().int().min(1).max(65_535), + path: boundedText(2_048).refine((value) => value.startsWith('/')), + }) + .strict() + .optional(), + }) + .strict(), + lifecycle: lifecycleSchema, + reconciliation: reconciliationSchema, + }) + .strict() + .superRefine((value, context) => { + if ( + value.lifecycle.mode === 'on_demand' && + value.reconciliation.policy === 'automatic' && + value.reconciliation.trigger.kind !== 'activation' + ) { + context.addIssue({ + code: 'custom', + message: 'An on-demand deployment can only reconcile during activation', + path: ['reconciliation'], + }); + } + if ( + value.lifecycle.mode === 'supervised' && + value.reconciliation.policy === 'automatic' && + value.reconciliation.trigger.kind === 'activation' + ) { + context.addIssue({ + code: 'custom', + message: 'A supervised deployment requires a scheduled reconciliation trigger', + path: ['reconciliation'], + }); + } + if ( + value.lifecycle.mode === 'supervised' && + value.reconciliation.policy === 'automatic' && + value.reconciliation.trigger.kind === 'scheduled' + ) { + const expected = + value.lifecycle.provider === 'systemd_user' + ? 'systemd_timer' + : value.lifecycle.provider === 'launch_agent' + ? 'launch_agent_timer' + : 'openrc_supervised_loop'; + if (value.reconciliation.trigger.provider !== expected) { + context.addIssue({ + code: 'custom', + message: 'The reconciliation trigger does not match the persisted supervisor provider', + path: ['reconciliation', 'trigger', 'provider'], + }); + } + } + }); + +const activeFenceStateSchema = z + .object({ + kind: z.literal('active'), + deploymentId: deploymentIdSchema, + configRevision: configRevisionSchema, + lifecycle: z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('on_demand') }).strict(), + z + .object({ + mode: z.literal('supervised'), + provider: providerSchema, + }) + .strict(), + ]), + }) + .strict(); + +const lifecycleFenceSchema = z + .object({ + schemaVersion: z.literal(SCHEMA_VERSION), + rootId: z.string().regex(ROOT_ID_PATTERN), + revision: z.string().regex(UUID_PATTERN), + state: z.discriminatedUnion('kind', [ + activeFenceStateSchema, + z + .object({ + kind: z.literal('transition'), + deploymentId: deploymentIdSchema, + transactionId: deploymentIdSchema, + fromConfigRevision: configRevisionSchema.nullable(), + toConfigRevision: configRevisionSchema.nullable(), + }) + .strict(), + z + .object({ + kind: z.literal('blocked'), + deploymentId: deploymentIdSchema, + transactionId: deploymentIdSchema, + reasonCode: boundedText(256), + }) + .strict(), + ]), + }) + .strict(); + +const managedLaunchClaimSchema = z + .object({ + deploymentId: deploymentIdSchema, + configRevision: configRevisionSchema, + lifecycle: z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('on_demand') }).strict(), + z + .object({ + mode: z.literal('supervised'), + provider: providerSchema, + }) + .strict(), + ]), + }) + .strict(); + +export type RuntimeHostSupervisorProvider = z.infer; +export type RuntimeHostReconciliationProvider = z.infer; +export type RuntimeHostManagedDeploymentConfig = z.infer; +export type RuntimeHostLifecycleFence = z.infer; +export type RuntimeHostManagedLaunchClaim = z.infer; +export type RuntimeHostActiveLifecycleFence = RuntimeHostLifecycleFence & { + readonly state: z.infer; +}; + +export type RuntimeHostManagedLaunchRejection = + | 'managed_root_requires_operator' + | 'deployment_fence_missing' + | 'deployment_fence_mismatch' + | 'deployment_transition_in_progress' + | 'deployment_needs_repair'; + +export class RuntimeHostManagedDeploymentError extends Error { + constructor( + readonly code: + | 'invalid_config' + | 'invalid_fence' + | 'deployment_io_failed' + | 'lifecycle_owner_exists' + | 'lifecycle_owner_changed' + | RuntimeHostManagedLaunchRejection, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RuntimeHostManagedDeploymentError'; + } +} + +export function decodeRuntimeHostManagedDeploymentConfig( + value: unknown, +): RuntimeHostManagedDeploymentConfig { + try { + return managedDeploymentConfigSchema.parse(value); + } catch (error) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host managed deployment config is invalid', + { cause: error }, + ); + } +} + +export function decodeRuntimeHostLifecycleFence( + value: unknown, + expectedRootId?: string, +): RuntimeHostLifecycleFence { + try { + const fence = lifecycleFenceSchema.parse(value); + if (expectedRootId !== undefined && fence.rootId !== expectedRootId) { + throw new Error('The lifecycle fence belongs to a different State Root'); + } + return fence; + } catch (error) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_fence', + 'The Runtime Host lifecycle fence is invalid', + { cause: error }, + ); + } +} + +export function decodeRuntimeHostManagedLaunchClaim(value: unknown): RuntimeHostManagedLaunchClaim { + try { + return managedLaunchClaimSchema.parse(value); + } catch (error) { + throw new RuntimeHostManagedDeploymentError( + 'deployment_fence_mismatch', + 'The Runtime Host managed launch claim is invalid', + { cause: error }, + ); + } +} + +export function resolveRuntimeHostManagedDeploymentConfigPath(clientDataRoot: string): string { + if (!isAbsolute(clientDataRoot)) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host client data root must be absolute', + ); + } + return join(resolve(clientDataRoot), RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE); +} + +export async function readRuntimeHostManagedDeploymentConfig( + path: string, +): Promise { + const value = await readBoundedJson(path, 'config'); + return value === undefined ? undefined : decodeRuntimeHostManagedDeploymentConfig(value); +} + +export async function writeRuntimeHostManagedDeploymentConfig( + path: string, + config: RuntimeHostManagedDeploymentConfig, +): Promise { + const canonical = decodeRuntimeHostManagedDeploymentConfig(config); + await withProcessLifetimeFileUpdateLock(path, () => writePrivateJson(path, canonical)); +} + +export async function readRuntimeHostLifecycleFence( + capability: StorageRootCapability<'interactive'>, +): Promise { + const { controlDirectory } = await prepareStorageRootControlDirectory(capability); + const value = await readBoundedJson( + join(controlDirectory, RUNTIME_HOST_LIFECYCLE_FENCE_FILE), + 'fence', + ); + return value === undefined + ? undefined + : decodeRuntimeHostLifecycleFence(value, capability.rootId); +} + +export async function claimRuntimeHostLifecycleFence( + capability: StorageRootCapability<'interactive'>, + claim: RuntimeHostManagedLaunchClaim, +): Promise<{ + readonly kind: 'applied' | 'unchanged'; + readonly fence: RuntimeHostLifecycleFence; +}> { + const canonicalClaim = decodeRuntimeHostManagedLaunchClaim(claim); + const { controlDirectory } = await prepareStorageRootControlDirectory(capability); + const path = join(controlDirectory, RUNTIME_HOST_LIFECYCLE_FENCE_FILE); + return withProcessLifetimeFileUpdateLock(path, async () => { + const currentValue = await readBoundedJson(path, 'fence'); + const current = + currentValue === undefined + ? undefined + : decodeRuntimeHostLifecycleFence(currentValue, capability.rootId); + if (current !== undefined) { + if (current.state.kind === 'active' && sameManagedLaunch(current.state, canonicalClaim)) { + return { kind: 'unchanged', fence: current }; + } + throw new RuntimeHostManagedDeploymentError( + 'lifecycle_owner_exists', + 'The State Root already has a managed lifecycle owner', + ); + } + const fence: RuntimeHostLifecycleFence = { + schemaVersion: SCHEMA_VERSION, + rootId: capability.rootId, + revision: randomUUID(), + state: { + kind: 'active', + ...canonicalClaim, + }, + }; + await writePrivateJson(path, fence); + return { kind: 'applied', fence }; + }); +} + +export async function releaseRuntimeHostLifecycleFence( + capability: StorageRootCapability<'interactive'>, + expected: { + readonly revision: string; + readonly deploymentId: string; + readonly configRevision: number; + }, +): Promise<'released' | 'unchanged'> { + const { controlDirectory } = await prepareStorageRootControlDirectory(capability); + const path = join(controlDirectory, RUNTIME_HOST_LIFECYCLE_FENCE_FILE); + return withProcessLifetimeFileUpdateLock(path, async () => { + const currentValue = await readBoundedJson(path, 'fence'); + if (currentValue === undefined) return 'unchanged'; + const current = decodeRuntimeHostLifecycleFence(currentValue, capability.rootId); + if ( + current.revision !== expected.revision || + current.state.kind !== 'active' || + current.state.deploymentId !== expected.deploymentId || + current.state.configRevision !== expected.configRevision + ) { + throw new RuntimeHostManagedDeploymentError( + 'lifecycle_owner_changed', + 'The Runtime Host lifecycle owner changed before release', + ); + } + await removePrivateJson(path); + return 'released'; + }); +} + +export function runtimeHostManagedLaunchRejection( + fence: RuntimeHostLifecycleFence | undefined, + claim: RuntimeHostManagedLaunchClaim | undefined, +): RuntimeHostManagedLaunchRejection | undefined { + if (fence === undefined) return claim === undefined ? undefined : 'deployment_fence_missing'; + if (fence.state.kind === 'transition') return 'deployment_transition_in_progress'; + if (fence.state.kind === 'blocked') return 'deployment_needs_repair'; + if (claim === undefined) return 'managed_root_requires_operator'; + return sameManagedLaunch(fence.state, claim) ? undefined : 'deployment_fence_mismatch'; +} + +export async function assertRuntimeHostManagedLaunchAuthorized( + capability: StorageRootCapability<'interactive'>, + claim: RuntimeHostManagedLaunchClaim | undefined, +): Promise { + const canonicalClaim = + claim === undefined ? undefined : decodeRuntimeHostManagedLaunchClaim(claim); + const rejection = runtimeHostManagedLaunchRejection( + await readRuntimeHostLifecycleFence(capability), + canonicalClaim, + ); + if (rejection !== undefined) { + throw new RuntimeHostManagedDeploymentError( + rejection, + managedLaunchRejectionMessage(rejection), + ); + } +} + +function sameManagedLaunch( + active: z.infer, + claim: RuntimeHostManagedLaunchClaim, +): boolean { + if ( + active.deploymentId !== claim.deploymentId || + active.configRevision !== claim.configRevision || + active.lifecycle.mode !== claim.lifecycle.mode + ) { + return false; + } + return ( + active.lifecycle.mode !== 'supervised' || + (claim.lifecycle.mode === 'supervised' && + active.lifecycle.provider === claim.lifecycle.provider) + ); +} + +function managedLaunchRejectionMessage(rejection: RuntimeHostManagedLaunchRejection): string { + switch (rejection) { + case 'managed_root_requires_operator': + return 'The State Root is managed and must be activated through its operator'; + case 'deployment_fence_missing': + return 'The managed Runtime Host deployment has no lifecycle fence'; + case 'deployment_fence_mismatch': + return 'The Runtime Host launch does not match the active lifecycle owner'; + case 'deployment_transition_in_progress': + return 'The Runtime Host deployment is changing lifecycle owner'; + case 'deployment_needs_repair': + return 'The Runtime Host deployment requires repair before activation'; + } +} + +async function readBoundedJson( + path: string, + kind: 'config' | 'fence', +): Promise { + let target: Awaited>; + try { + target = await lstat(path); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw deploymentIo('Unable to inspect the Runtime Host managed deployment ' + kind, error); + } + if (!target.isFile() || target.isSymbolicLink() || target.size > MAX_DOCUMENT_BYTES) { + throw new RuntimeHostManagedDeploymentError( + kind === 'config' ? 'invalid_config' : 'invalid_fence', + 'The Runtime Host managed deployment ' + kind + ' must be a bounded regular file', + ); + } + try { + const contents = await readFile(path, 'utf8'); + if (Buffer.byteLength(contents, 'utf8') > MAX_DOCUMENT_BYTES) { + throw new RuntimeHostManagedDeploymentError( + kind === 'config' ? 'invalid_config' : 'invalid_fence', + 'The Runtime Host managed deployment ' + kind + ' exceeds its size limit', + ); + } + return JSON.parse(contents) as unknown; + } catch (error) { + if (error instanceof RuntimeHostManagedDeploymentError) throw error; + throw new RuntimeHostManagedDeploymentError( + kind === 'config' ? 'invalid_config' : 'invalid_fence', + 'The Runtime Host managed deployment ' + kind + ' is not valid JSON', + { cause: error }, + ); + } +} + +async function writePrivateJson(path: string, value: unknown): Promise { + const contents = JSON.stringify(value, null, 2) + '\n'; + if (Buffer.byteLength(contents, 'utf8') > MAX_DOCUMENT_BYTES) { + throw new RuntimeHostManagedDeploymentError( + 'deployment_io_failed', + 'The Runtime Host managed deployment document exceeds its size limit', + ); + } + const temporaryPath = path + '.' + process.pid + '.' + randomUUID() + '.tmp'; + let published = false; + try { + const handle = await open(temporaryPath, 'wx', 0o600); + try { + await handle.writeFile(contents, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + if (process.platform !== 'win32') await chmod(temporaryPath, 0o600); + await rename(temporaryPath, path); + published = true; + await syncDirectory(dirname(path)); + } catch (error) { + if (error instanceof RuntimeHostManagedDeploymentError) throw error; + throw deploymentIo('Unable to publish the Runtime Host managed deployment document', error); + } finally { + if (!published) await rm(temporaryPath, { force: true }); + } +} + +async function removePrivateJson(path: string): Promise { + try { + await unlink(path); + await syncDirectory(dirname(path)); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return; + throw deploymentIo('Unable to remove the Runtime Host lifecycle fence', error); + } +} + +async function syncDirectory(path: string): Promise { + const directory = await open(path, 'r'); + try { + await directory.sync(); + } finally { + await directory.close(); + } +} + +function deploymentIo(message: string, cause: unknown): RuntimeHostManagedDeploymentError { + return cause instanceof RuntimeHostManagedDeploymentError + ? cause + : new RuntimeHostManagedDeploymentError('deployment_io_failed', message, { + cause, + }); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/packages/runtime-host/src/server/candidate.ts b/packages/runtime-host/src/server/candidate.ts index 767e72e168..ac38a681cf 100644 --- a/packages/runtime-host/src/server/candidate.ts +++ b/packages/runtime-host/src/server/candidate.ts @@ -18,6 +18,10 @@ */ import { resolveExistingStorageRoot, tryAcquireStateRootOwner } from '@maka/storage/root-authority'; +import { + assertRuntimeHostManagedLaunchAuthorized, + type RuntimeHostManagedLaunchClaim, +} from '../operator/managed-deployment.js'; import type { RuntimeHostCompositionSource } from './host-composition.js'; import { RuntimeHostKernel } from './host-kernel.js'; @@ -28,6 +32,7 @@ export interface InteractiveRuntimeHostCandidateOptions { idleGraceMs?: number; handshakeTimeoutMs?: number; generation?: string; + managedLaunchClaim?: RuntimeHostManagedLaunchClaim; } export type InteractiveRuntimeHostCandidateResult = @@ -43,6 +48,7 @@ export async function startInteractiveRuntimeHostCandidate( kind: 'interactive', expectedRootId: options.expectedRootId, }); + await assertRuntimeHostManagedLaunchAuthorized(capability, options.managedLaunchClaim); const owner = await tryAcquireStateRootOwner(capability); if (!owner) return { kind: 'loser' }; const host = await RuntimeHostKernel.start({ diff --git a/packages/runtime-host/src/server/execution-service.ts b/packages/runtime-host/src/server/execution-service.ts index 33a9d1014b..316e57e8b2 100644 --- a/packages/runtime-host/src/server/execution-service.ts +++ b/packages/runtime-host/src/server/execution-service.ts @@ -22,6 +22,10 @@ import { createExecutionRuntimeHostCompositionSource, type ExecutionRuntimeHostCompositionDependencies, } from './execution-composition-factory.js'; +import { + assertRuntimeHostManagedLaunchAuthorized, + type RuntimeHostManagedLaunchClaim, +} from '../operator/managed-deployment.js'; import { RuntimeHostKernel } from './host-kernel.js'; import { openRuntimeHostAccessAuthority } from './access-authority.js'; import { startRuntimeHostServiceListenerSet } from './listener-set.js'; @@ -34,6 +38,7 @@ export interface ExecutionRuntimeHostServiceOptions { readonly projectDirectoryRoots?: readonly PublishedProjectDirectoryRoot[]; readonly handshakeTimeoutMs?: number; readonly shutdownGraceMs?: number; + readonly managedLaunchClaim?: RuntimeHostManagedLaunchClaim; readonly websocket?: Omit< StartRuntimeHostWebSocketListenerOptions, 'accessAuthority' | 'accept' | 'isReady' @@ -58,6 +63,7 @@ export async function startExecutionRuntimeHostService( ): Promise { const composition = await createExecutionRuntimeHostCompositionSource(options, dependencies); const capability = await resolveStorageRoot({ path: options.rootPath, kind: 'interactive' }); + await assertRuntimeHostManagedLaunchAuthorized(capability, options.managedLaunchClaim); const owner = await tryAcquireStateRootOwner(capability); if (!owner) throw new RuntimeHostRootAlreadyOwnedError(capability.canonicalPath); try { From 881ac6ae7837d48e6d4171c3ee3fb276c69136db Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Thu, 27 Aug 2026 15:59:23 +0800 Subject: [PATCH 02/11] fix(runtime-host): unify managed launch authority Generated-by: Codex --- .../src/__tests__/candidate-cli.test.ts | 2 +- .../candidate-startup-failure.test.ts | 23 + .../src/__tests__/host-kernel.test.ts | 57 +- .../src/__tests__/managed-deployment.test.ts | 348 ++++++---- .../src/__tests__/startup-error.test.ts | 6 +- packages/runtime-host/src/candidate-cli.ts | 40 +- .../src/candidate-startup-failure.ts | 44 +- .../src/client/connect-or-spawn.ts | 32 +- packages/runtime-host/src/client/launcher.ts | 7 +- .../runtime-host/src/client/startup-error.ts | 16 +- .../src/control/startup-diagnostic.ts | 8 +- packages/runtime-host/src/operator/index.ts | 19 +- .../src/operator/managed-deployment.ts | 617 ++++++++++-------- packages/runtime-host/src/server/candidate.ts | 23 +- .../src/server/execution-service.ts | 23 +- 15 files changed, 767 insertions(+), 498 deletions(-) diff --git a/packages/runtime-host/src/__tests__/candidate-cli.test.ts b/packages/runtime-host/src/__tests__/candidate-cli.test.ts index 51ff39ec9b..64a4f996ce 100644 --- a/packages/runtime-host/src/__tests__/candidate-cli.test.ts +++ b/packages/runtime-host/src/__tests__/candidate-cli.test.ts @@ -98,7 +98,7 @@ test('rejects partial or contradictory managed launch claims', () => { '--managed-provider', 'systemd_user', ]), - /cannot declare a supervisor provider/u, + /Invalid Runtime Host candidate argument: --managed-provider/u, ); }); diff --git a/packages/runtime-host/src/__tests__/candidate-startup-failure.test.ts b/packages/runtime-host/src/__tests__/candidate-startup-failure.test.ts index 7ff85b3db4..679f85003c 100644 --- a/packages/runtime-host/src/__tests__/candidate-startup-failure.test.ts +++ b/packages/runtime-host/src/__tests__/candidate-startup-failure.test.ts @@ -107,3 +107,26 @@ test('classifies unknown startup failures without serializing their message', () assert.deepEqual(failure, { reason: 'internal_startup_failure' }); assert.equal(JSON.stringify(failure).includes('private'), false); }); + +test('preserves managed authority rejections as permanent bounded diagnostics', () => { + const reasons = [ + 'managed_root_requires_operator', + 'deployment_record_missing', + 'deployment_claim_mismatch', + 'deployment_lifecycle_mismatch', + 'deployment_record_invalid', + ] as const; + + for (const reason of reasons) { + const failure = classifyCandidateStartupFailure( + Object.assign(new Error('private deployment detail'), { code: reason }), + ); + assert.deepEqual(failure, { reason }); + assert.deepEqual( + candidateStartupFailureForExitCode(candidateStartupFailureExitCode(failure)), + failure, + ); + assert.equal(isPermanentCandidateStartupFailure(failure), true); + assert.equal(JSON.stringify(failure).includes('private'), false); + } +}); diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 1e6fa03035..3d66038ae8 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -59,7 +59,10 @@ import { readCandidateStartupDiagnostic, writeCandidateStartupDiagnostic, } from '../control/startup-diagnostic.js'; -import { claimRuntimeHostLifecycleFence } from '../operator/managed-deployment.js'; +import { + claimRuntimeHostManagedDeployment, + type RuntimeHostManagedOnDemandDeploymentConfig, +} from '../operator/managed-deployment.js'; import { removePosixEndpointDirectories } from './fixtures/endpoint-hygiene.js'; import { decodeHostFrame, @@ -106,6 +109,7 @@ const CURRENT_PROTOCOL = { const LEGACY_PROTOCOL = { min: 1, max: 1 } as const; const STARTUP_ATTEMPT_A = '00000000-0000-4000-8000-000000000001'; const STARTUP_ATTEMPT_B = '00000000-0000-4000-8000-000000000002'; +const MANAGED_PACKAGE_INTEGRITY = 'sha512-' + Buffer.alloc(64, 1).toString('base64'); const KERNEL_CANDIDATE_ENTRYPOINT = new URL('./fixtures/kernel-candidate.js', import.meta.url); const KERNEL_COMPOSITION = defineInteractiveRuntimeHostComposition(async () => ({ handlers: createUnavailableDomainOperationHandlers(), @@ -161,11 +165,12 @@ describe('non-serving Runtime Host kernel', () => { test('a managed State Root refuses an ordinary candidate launch before election', async () => { await withHostPaths(async (paths) => { const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); - await claimRuntimeHostLifecycleFence(capability, { - deploymentId: '00000000-0000-4000-8000-000000000001', - configRevision: 1, - lifecycle: { mode: 'on_demand' }, - }); + const managedDeploymentAuthority = { authorityRoot: join(paths.base, 'managed-authority') }; + await claimRuntimeHostManagedDeployment( + capability, + managedDeploymentConfig(capability), + managedDeploymentAuthority, + ); let launches = 0; const result = await connectOrSpawnRuntimeHostWithDependencies( @@ -187,6 +192,7 @@ describe('non-serving Runtime Host kernel', () => { launches += 1; throw new Error('managed root must not launch without its operator claim'); }, + managedDeploymentAuthority, }, ); @@ -201,12 +207,12 @@ describe('non-serving Runtime Host kernel', () => { test('a matching managed claim reaches the existing candidate election', async () => { await withHostPaths(async (paths) => { const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); - const claim = { - deploymentId: '00000000-0000-4000-8000-000000000001', - configRevision: 1, - lifecycle: { mode: 'on_demand' as const }, - }; - await claimRuntimeHostLifecycleFence(capability, claim); + const managedDeploymentAuthority = { authorityRoot: join(paths.base, 'managed-authority') }; + const { claim } = await claimRuntimeHostManagedDeployment( + capability, + managedDeploymentConfig(capability), + managedDeploymentAuthority, + ); let launches = 0; const result = await connectOrSpawnRuntimeHostWithDependencies( @@ -229,6 +235,7 @@ describe('non-serving Runtime Host kernel', () => { launches += 1; return { spawned: new Promise(() => undefined) }; }, + managedDeploymentAuthority, }, ); @@ -3158,6 +3165,32 @@ describe('non-serving Runtime Host kernel', () => { }); }); +function managedDeploymentConfig( + capability: StorageRootCapability<'interactive'>, +): RuntimeHostManagedOnDemandDeploymentConfig { + return { + schemaVersion: 1, + deploymentId: '00000000-0000-4000-8000-000000000001', + configRevision: 1, + deploymentRoot: '/opt/maka/runtime-host', + root: { path: capability.canonicalPath, id: capability.rootId }, + projectDirectoryRoots: [], + launch: { + kind: 'exact_package', + nodePath: '/usr/bin/node', + cliPath: '/opt/maka/runtime-host/versions/1.2.3/cli.js', + package: { + kind: 'npm_registry', + version: '1.2.3', + integrity: MANAGED_PACKAGE_INTEGRITY, + }, + }, + listeners: { localIpc: true }, + lifecycle: { mode: 'on_demand', availability: 'activation' }, + reconciliation: { trigger: 'activation' }, + }; +} + function testComposition( overrides: Partial> = {}, ): RuntimeHostComposition { diff --git a/packages/runtime-host/src/__tests__/managed-deployment.test.ts b/packages/runtime-host/src/__tests__/managed-deployment.test.ts index 9c93b034b5..19a2a5b157 100644 --- a/packages/runtime-host/src/__tests__/managed-deployment.test.ts +++ b/packages/runtime-host/src/__tests__/managed-deployment.test.ts @@ -18,45 +18,55 @@ */ import assert from 'node:assert/strict'; -import { lstat, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import test from 'node:test'; -import { resolveStorageRoot } from '@maka/storage/root-authority'; +import { resolveStorageRoot, tryAcquireStateRootOwner } from '@maka/storage/root-authority'; import { RuntimeHostManagedDeploymentError, - assertRuntimeHostManagedLaunchAuthorized, - claimRuntimeHostLifecycleFence, + claimRuntimeHostManagedDeployment, decodeRuntimeHostManagedDeploymentConfig, - readRuntimeHostLifecycleFence, readRuntimeHostManagedDeploymentConfig, - releaseRuntimeHostLifecycleFence, + resolveRuntimeHostManagedDeploymentAuthorityRoot, resolveRuntimeHostManagedDeploymentConfigPath, + runtimeHostManagedLaunchClaim, runtimeHostManagedLaunchRejection, - writeRuntimeHostManagedDeploymentConfig, + tryAcquireRuntimeHostLaunchOwner, + type RuntimeHostManagedDeploymentAuthorityOptions, type RuntimeHostManagedDeploymentConfig, - type RuntimeHostManagedLaunchClaim, } from '../operator/managed-deployment.js'; const DEPLOYMENT_ID = '00000000-0000-4000-8000-000000000001'; const OTHER_DEPLOYMENT_ID = '00000000-0000-4000-8000-000000000002'; -const ROOT_ID = 'a'.repeat(64); const PACKAGE_INTEGRITY = 'sha512-' + Buffer.alloc(64, 1).toString('base64'); -const ON_DEMAND_CLAIM: RuntimeHostManagedLaunchClaim = { - deploymentId: DEPLOYMENT_ID, - configRevision: 1, - lifecycle: { mode: 'on_demand' }, -}; - -function config( - overrides: Partial = {}, -): RuntimeHostManagedDeploymentConfig { + +interface Fixture { + readonly capability: Awaited>; + readonly authority: RuntimeHostManagedDeploymentAuthorityOptions; + readonly config: RuntimeHostManagedDeploymentConfig; +} + +async function fixture(t: test.TestContext): Promise { + const rootPath = await mkdtemp(join(tmpdir(), 'maka-managed-root-')); + const authorityRoot = await mkdtemp(join(tmpdir(), 'maka-managed-authority-')); + t.after(() => rm(rootPath, { recursive: true, force: true })); + t.after(() => rm(authorityRoot, { recursive: true, force: true })); + const capability = await resolveStorageRoot({ path: rootPath, kind: 'interactive' }); + return { + capability, + authority: { authorityRoot }, + config: createConfig(capability.canonicalPath, capability.rootId), + }; +} + +function createConfig(rootPath: string, rootId: string): RuntimeHostManagedDeploymentConfig { return { schemaVersion: 1, deploymentId: DEPLOYMENT_ID, configRevision: 1, deploymentRoot: '/opt/maka/runtime-host', - root: { path: '/srv/maka/state', id: ROOT_ID }, + root: { path: rootPath, id: rootId }, projectDirectoryRoots: [{ label: 'projects', path: '/srv/projects' }], launch: { kind: 'exact_package', @@ -77,27 +87,21 @@ function config( }, }, lifecycle: { mode: 'on_demand', availability: 'activation' }, - reconciliation: { - policy: 'automatic', - trigger: { kind: 'activation' }, - }, - ...overrides, + reconciliation: { trigger: 'activation' }, }; } -async function root(t: test.TestContext) { - const path = await mkdtemp(join(tmpdir(), 'maka-managed-deployment-')); - t.after(() => rm(path, { recursive: true, force: true })); - return resolveStorageRoot({ path, kind: 'interactive' }); -} - -test('strictly decodes the canonical on-demand deployment contract', () => { - assert.deepEqual(decodeRuntimeHostManagedDeploymentConfig(config()), config()); +test('strictly decodes every level of the canonical deployment contract', () => { + const config = createConfig('/srv/maka/state', 'a'.repeat(64)); + assert.deepEqual(decodeRuntimeHostManagedDeploymentConfig(config), config); assert.throws( () => decodeRuntimeHostManagedDeploymentConfig({ - ...config(), - credential: 'must-not-be-persisted', + ...config, + launch: { + ...config.launch, + package: { ...config.launch.package, credential: 'must-not-be-persisted' }, + }, }), (error: unknown) => error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', @@ -105,134 +109,230 @@ test('strictly decodes the canonical on-demand deployment contract', () => { }); test('rejects lifecycle and reconciliation combinations that cannot be honored', () => { + const config = createConfig('/srv/maka/state', 'a'.repeat(64)); assert.throws( () => - decodeRuntimeHostManagedDeploymentConfig( - config({ - reconciliation: { - policy: 'automatic', - trigger: { kind: 'scheduled', provider: 'systemd_timer' }, - }, - }), - ), + decodeRuntimeHostManagedDeploymentConfig({ + ...config, + reconciliation: { trigger: 'scheduled', provider: 'systemd_timer' }, + }), (error: unknown) => error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', ); assert.throws( () => - decodeRuntimeHostManagedDeploymentConfig( - config({ - lifecycle: { - mode: 'supervised', - provider: 'launch_agent', - availability: 'session', - }, - reconciliation: { - policy: 'automatic', - trigger: { kind: 'scheduled', provider: 'systemd_timer' }, - }, - }), - ), + decodeRuntimeHostManagedDeploymentConfig({ + ...config, + lifecycle: { mode: 'supervised', provider: 'launch_agent', availability: 'session' }, + reconciliation: { trigger: 'scheduled', provider: 'systemd_timer' }, + }), (error: unknown) => error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', ); }); -test('writes and reads a bounded private canonical deployment file', async (t) => { - const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-managed-config-')); - t.after(() => rm(clientDataRoot, { recursive: true, force: true })); - const path = resolveRuntimeHostManagedDeploymentConfigPath(clientDataRoot); - - await writeRuntimeHostManagedDeploymentConfig(path, config()); - - assert.deepEqual(await readRuntimeHostManagedDeploymentConfig(path), config()); - if (process.platform !== 'win32') { - assert.equal((await lstat(path)).mode & 0o777, 0o600); - } -}); - -test('rejects oversized deployment documents before parsing', async (t) => { - const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-managed-config-large-')); - t.after(() => rm(clientDataRoot, { recursive: true, force: true })); - const path = resolveRuntimeHostManagedDeploymentConfigPath(clientDataRoot); - await writeFile(path, 'x'.repeat(64 * 1024 + 1)); - - await assert.rejects( - readRuntimeHostManagedDeploymentConfig(path), - (error: unknown) => - error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', +test('resolves managed deployment authority under durable application data, never cache', () => { + assert.equal( + resolveRuntimeHostManagedDeploymentAuthorityRoot({ homeDir: '/home/maka', platform: 'linux' }), + '/home/maka/.local/share/Maka/runtime-host-deployments', + ); + assert.equal( + resolveRuntimeHostManagedDeploymentAuthorityRoot({ + homeDir: '/Users/maka', + platform: 'darwin', + }), + '/Users/maka/Library/Application Support/Maka/runtime-host-deployments', + ); + assert.equal( + resolveRuntimeHostManagedDeploymentAuthorityRoot({ + homeDir: 'C:\\Users\\maka', + platform: 'win32', + }), + 'C:\\Users\\maka\\AppData\\Local\\Maka\\runtime-host-deployments', ); }); -test('claims one idempotent lifecycle owner and refuses a competing deployment', async (t) => { - const capability = await root(t); - const first = await claimRuntimeHostLifecycleFence(capability, ON_DEMAND_CLAIM); - const retried = await claimRuntimeHostLifecycleFence(capability, ON_DEMAND_CLAIM); +test('claims one canonical deployment while fencing State Root ownership', async (t) => { + const input = await fixture(t); + const claimed = await claimRuntimeHostManagedDeployment( + input.capability, + input.config, + input.authority, + ); + const retried = await claimRuntimeHostManagedDeployment( + input.capability, + input.config, + input.authority, + ); - assert.equal(first.kind, 'applied'); + assert.equal(claimed.kind, 'applied'); assert.equal(retried.kind, 'unchanged'); - assert.deepEqual(retried.fence, first.fence); + assert.deepEqual(retried.claim, claimed.claim); + assert.deepEqual( + await readRuntimeHostManagedDeploymentConfig(input.capability, input.authority), + input.config, + ); + const path = resolveRuntimeHostManagedDeploymentConfigPath( + input.capability.rootId, + input.authority, + ); + if (process.platform !== 'win32') assert.equal((await lstat(path)).mode & 0o777, 0o600); + await assert.rejects( - claimRuntimeHostLifecycleFence(capability, { - ...ON_DEMAND_CLAIM, - deploymentId: OTHER_DEPLOYMENT_ID, - }), + claimRuntimeHostManagedDeployment( + input.capability, + { ...input.config, deploymentId: OTHER_DEPLOYMENT_ID }, + input.authority, + ), (error: unknown) => error instanceof RuntimeHostManagedDeploymentError && error.code === 'lifecycle_owner_exists', ); }); -test('releases only the exact observed lifecycle fence revision', async (t) => { - const capability = await root(t); - const claimed = await claimRuntimeHostLifecycleFence(capability, ON_DEMAND_CLAIM); +test('cannot publish a managed deployment while another Host owns the State Root', async (t) => { + const input = await fixture(t); + const owner = await tryAcquireStateRootOwner(input.capability); + assert.ok(owner); + try { + await assert.rejects( + claimRuntimeHostManagedDeployment(input.capability, input.config, input.authority), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && error.code === 'state_root_owned', + ); + } finally { + await owner.close(); + } + assert.equal( + await readRuntimeHostManagedDeploymentConfig(input.capability, input.authority), + undefined, + ); +}); +test('launch acquisition atomically joins deployment authorization and State Root ownership', async (t) => { + const input = await fixture(t); + const unmanagedOwner = await tryAcquireRuntimeHostLaunchOwner( + input.capability, + 'on_demand', + undefined, + input.authority, + ); + assert.ok(unmanagedOwner); + try { + await assert.rejects( + claimRuntimeHostManagedDeployment(input.capability, input.config, input.authority), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && error.code === 'state_root_owned', + ); + } finally { + await unmanagedOwner.close(); + } + + const managed = await claimRuntimeHostManagedDeployment( + input.capability, + input.config, + input.authority, + ); await assert.rejects( - releaseRuntimeHostLifecycleFence(capability, { - revision: OTHER_DEPLOYMENT_ID, - deploymentId: DEPLOYMENT_ID, - configRevision: 1, - }), + tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', undefined, input.authority), (error: unknown) => error instanceof RuntimeHostManagedDeploymentError && - error.code === 'lifecycle_owner_changed', + error.code === 'managed_root_requires_operator', ); - assert.equal( - await releaseRuntimeHostLifecycleFence(capability, { - revision: claimed.fence.revision, - deploymentId: DEPLOYMENT_ID, - configRevision: 1, - }), - 'released', + await assert.rejects( + tryAcquireRuntimeHostLaunchOwner( + input.capability, + 'supervised', + managed.claim, + input.authority, + ), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && + error.code === 'deployment_lifecycle_mismatch', ); - assert.equal(await readRuntimeHostLifecycleFence(capability), undefined); + const managedOwner = await tryAcquireRuntimeHostLaunchOwner( + input.capability, + 'on_demand', + managed.claim, + input.authority, + ); + assert.ok(managedOwner); + await managedOwner.close(); }); -test('maps lifecycle fence states and claims to fail-closed launch decisions', async (t) => { - const capability = await root(t); - assert.equal(runtimeHostManagedLaunchRejection(undefined, undefined), undefined); - assert.equal( - runtimeHostManagedLaunchRejection(undefined, ON_DEMAND_CLAIM), - 'deployment_fence_missing', +test('concurrent install and unmanaged launch cannot both cross the authority boundary', async (t) => { + const input = await fixture(t); + const [claimResult, launchResult] = await Promise.allSettled([ + claimRuntimeHostManagedDeployment(input.capability, input.config, input.authority), + tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', undefined, input.authority), + ]); + + const claimSucceeded = claimResult.status === 'fulfilled'; + const launchOwner = launchResult.status === 'fulfilled' ? launchResult.value : undefined; + assert.notEqual(claimSucceeded, launchOwner !== undefined); + await launchOwner?.close(); + + if (claimSucceeded) { + assert.equal(launchResult.status, 'rejected'); + assert.ok(launchResult.reason instanceof RuntimeHostManagedDeploymentError); + assert.equal(launchResult.reason.code, 'managed_root_requires_operator'); + } else { + assert.equal(claimResult.status, 'rejected'); + assert.ok(claimResult.reason instanceof RuntimeHostManagedDeploymentError); + assert.equal(claimResult.reason.code, 'state_root_owned'); + } +}); + +test('concurrent managed activations elect exactly one State Root owner', async (t) => { + const input = await fixture(t); + const { claim } = await claimRuntimeHostManagedDeployment( + input.capability, + input.config, + input.authority, ); + const owners = await Promise.all([ + tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', claim, input.authority), + tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', claim, input.authority), + ]); - const claimed = await claimRuntimeHostLifecycleFence(capability, ON_DEMAND_CLAIM); + assert.equal(owners.filter((owner) => owner !== undefined).length, 1); + await Promise.all(owners.map((owner) => owner?.close())); +}); + +test('maps missing and stale claims to fail-closed launch decisions', () => { + const config = createConfig('/srv/maka/state', 'a'.repeat(64)); + const claim = runtimeHostManagedLaunchClaim(config); + assert.equal(runtimeHostManagedLaunchRejection(undefined, undefined, 'on_demand'), undefined); + assert.equal( + runtimeHostManagedLaunchRejection(undefined, claim, 'on_demand'), + 'deployment_record_missing', + ); assert.equal( - runtimeHostManagedLaunchRejection(claimed.fence, undefined), + runtimeHostManagedLaunchRejection(config, undefined, 'on_demand'), 'managed_root_requires_operator', ); assert.equal( - runtimeHostManagedLaunchRejection(claimed.fence, { - ...ON_DEMAND_CLAIM, - configRevision: 2, - }), - 'deployment_fence_mismatch', + runtimeHostManagedLaunchRejection( + config, + { ...claim, configRevision: claim.configRevision + 1 }, + 'on_demand', + ), + 'deployment_claim_mismatch', ); - assert.equal(runtimeHostManagedLaunchRejection(claimed.fence, ON_DEMAND_CLAIM), undefined); + assert.equal(runtimeHostManagedLaunchRejection(config, claim, 'on_demand'), undefined); +}); + +test('rejects oversized deployment records before parsing', async (t) => { + const input = await fixture(t); + const path = resolveRuntimeHostManagedDeploymentConfigPath( + input.capability.rootId, + input.authority, + ); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, 'x'.repeat(64 * 1024 + 1)); + await assert.rejects( - assertRuntimeHostManagedLaunchAuthorized(capability, undefined), + readRuntimeHostManagedDeploymentConfig(input.capability, input.authority), (error: unknown) => - error instanceof RuntimeHostManagedDeploymentError && - error.code === 'managed_root_requires_operator', + error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', ); - await assert.doesNotReject(assertRuntimeHostManagedLaunchAuthorized(capability, ON_DEMAND_CLAIM)); }); diff --git a/packages/runtime-host/src/__tests__/startup-error.test.ts b/packages/runtime-host/src/__tests__/startup-error.test.ts index 44d6b0b7da..8afa512f10 100644 --- a/packages/runtime-host/src/__tests__/startup-error.test.ts +++ b/packages/runtime-host/src/__tests__/startup-error.test.ts @@ -43,11 +43,11 @@ test('presents a managed root bypass as an operator-required permanent error', ( assert.match(error.message, /MANAGED_ROOT_REQUIRES_OPERATOR/u); }); -test('presents an uncertain lifecycle owner as requiring repair', () => { - const error = runtimeHostStartupError('deployment_needs_repair'); +test('presents an invalid deployment record as requiring repair', () => { + const error = runtimeHostStartupError('deployment_record_invalid'); assert.ok(error instanceof RuntimeHostPermanentReconnectError); assert.match(error.message, /repair/u); - assert.match(error.message, /DEPLOYMENT_NEEDS_REPAIR/u); + assert.match(error.message, /DEPLOYMENT_RECORD_INVALID/u); }); test('keeps an unresponsive Host retryable and includes bounded diagnostics', () => { diff --git a/packages/runtime-host/src/candidate-cli.ts b/packages/runtime-host/src/candidate-cli.ts index 31232e7cc8..8e298f6485 100644 --- a/packages/runtime-host/src/candidate-cli.ts +++ b/packages/runtime-host/src/candidate-cli.ts @@ -21,7 +21,8 @@ import type { InteractiveRuntimeHostCandidateOptions } from './server/candidate. import { isCandidateStartupAttemptId } from './candidate-startup-failure.js'; import { decodeRuntimeHostManagedLaunchClaim, - type RuntimeHostManagedLaunchClaim, + isRuntimeHostManagedOnDemandLaunchClaim, + type RuntimeHostManagedOnDemandLaunchClaim, } from './operator/managed-deployment.js'; export interface ParsedInteractiveRuntimeHostCandidateArguments @@ -43,7 +44,6 @@ export function parseInteractiveRuntimeHostCandidateArguments( 'managed-deployment-id', 'managed-config-revision', 'managed-lifecycle-mode', - 'managed-provider', ]); const values = new Map(); for (let index = 0; index < args.length; index += 2) { @@ -83,17 +83,11 @@ export function parseInteractiveRuntimeHostCandidateArguments( function readManagedLaunchClaim( values: ReadonlyMap, -): RuntimeHostManagedLaunchClaim | undefined { +): RuntimeHostManagedOnDemandLaunchClaim | undefined { const deploymentId = values.get('managed-deployment-id'); const rawRevision = values.get('managed-config-revision'); const lifecycleMode = values.get('managed-lifecycle-mode'); - const provider = values.get('managed-provider'); - if ( - deploymentId === undefined && - rawRevision === undefined && - lifecycleMode === undefined && - provider === undefined - ) { + if (deploymentId === undefined && rawRevision === undefined && lifecycleMode === undefined) { return undefined; } if (deploymentId === undefined || rawRevision === undefined || lifecycleMode === undefined) { @@ -103,24 +97,16 @@ function readManagedLaunchClaim( if (!Number.isSafeInteger(configRevision) || configRevision <= 0) { throw new Error('Invalid --managed-config-revision'); } - if (lifecycleMode === 'on_demand') { - if (provider !== undefined) { - throw new Error('An on-demand Runtime Host candidate cannot declare a supervisor provider'); - } - return decodeRuntimeHostManagedLaunchClaim({ - deploymentId, - configRevision, - lifecycle: { mode: lifecycleMode }, - }); - } - if (lifecycleMode === 'supervised' && provider !== undefined) { - return decodeRuntimeHostManagedLaunchClaim({ - deploymentId, - configRevision, - lifecycle: { mode: lifecycleMode, provider }, - }); + if (lifecycleMode !== 'on_demand') throw new Error('Invalid --managed-lifecycle-mode'); + const claim = decodeRuntimeHostManagedLaunchClaim({ + deploymentId, + configRevision, + lifecycle: { mode: lifecycleMode }, + }); + if (!isRuntimeHostManagedOnDemandLaunchClaim(claim)) { + throw new Error('Invalid --managed-lifecycle-mode'); } - throw new Error('Invalid --managed-lifecycle-mode'); + return claim; } function readGeneration(values: Map): string { diff --git a/packages/runtime-host/src/candidate-startup-failure.ts b/packages/runtime-host/src/candidate-startup-failure.ts index 1c4a7d43a6..dbf330a35c 100644 --- a/packages/runtime-host/src/candidate-startup-failure.ts +++ b/packages/runtime-host/src/candidate-startup-failure.ts @@ -17,11 +17,19 @@ * under the License. */ -export type CandidateStartupFailureReason = - | 'stored_data_incompatible' - | 'operational_state_migration_blocked' - | 'local_ipc_security_failed' - | 'internal_startup_failure'; +export const CANDIDATE_STARTUP_FAILURE_REASONS = [ + 'stored_data_incompatible', + 'operational_state_migration_blocked', + 'local_ipc_security_failed', + 'internal_startup_failure', + 'managed_root_requires_operator', + 'deployment_record_missing', + 'deployment_claim_mismatch', + 'deployment_lifecycle_mismatch', + 'deployment_record_invalid', +] as const; + +export type CandidateStartupFailureReason = (typeof CANDIDATE_STARTUP_FAILURE_REASONS)[number]; export interface CandidateStartupFailure { readonly reason: CandidateStartupFailureReason; @@ -34,11 +42,24 @@ export interface CandidateStartupFailureReport extends CandidateStartupFailure { const STARTUP_ATTEMPT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const MANAGED_AUTHORITY_FAILURES = [ + 'managed_root_requires_operator', + 'deployment_record_missing', + 'deployment_claim_mismatch', + 'deployment_lifecycle_mismatch', + 'deployment_record_invalid', +] as const; + const EXIT_CODE_BY_REASON: Readonly> = { stored_data_incompatible: 65, operational_state_migration_blocked: 78, local_ipc_security_failed: 77, internal_startup_failure: 70, + managed_root_requires_operator: 80, + deployment_record_missing: 81, + deployment_claim_mismatch: 82, + deployment_lifecycle_mismatch: 83, + deployment_record_invalid: 84, }; export function classifyCandidateStartupFailure(error: unknown): CandidateStartupFailure { @@ -52,17 +73,24 @@ export function classifyCandidateStartupFailure(error: unknown): CandidateStartu if (errors.some((candidate) => errorCode(candidate) === 'insecure_endpoint_directory')) { return { reason: 'local_ipc_security_failed' }; } + for (const reason of MANAGED_AUTHORITY_FAILURES) { + if (errors.some((candidate) => errorCode(candidate) === reason)) return { reason }; + } return { reason: 'internal_startup_failure' }; } export function isPermanentCandidateStartupFailure( failure: CandidateStartupFailure | undefined, ): failure is CandidateStartupFailure & { - readonly reason: 'stored_data_incompatible' | 'operational_state_migration_blocked'; + readonly reason: Exclude< + CandidateStartupFailureReason, + 'local_ipc_security_failed' | 'internal_startup_failure' + >; } { return ( - failure?.reason === 'stored_data_incompatible' || - failure?.reason === 'operational_state_migration_blocked' + failure !== undefined && + failure.reason !== 'local_ipc_security_failed' && + failure.reason !== 'internal_startup_failure' ); } diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts index 46f55d2768..02c7b84388 100644 --- a/packages/runtime-host/src/client/connect-or-spawn.ts +++ b/packages/runtime-host/src/client/connect-or-spawn.ts @@ -59,9 +59,12 @@ import { } from '../control/startup-diagnostic.js'; import { decodeRuntimeHostManagedLaunchClaim, - readRuntimeHostLifecycleFence, + isRuntimeHostManagedOnDemandLaunchClaim, + readRuntimeHostManagedDeploymentConfig, runtimeHostManagedLaunchRejection, - type RuntimeHostManagedLaunchClaim, + RuntimeHostManagedDeploymentError, + type RuntimeHostManagedDeploymentAuthorityOptions, + type RuntimeHostManagedOnDemandLaunchClaim, type RuntimeHostManagedLaunchRejection, } from '../operator/managed-deployment.js'; import { abortable, waitForRuntimeHostReady } from './wait-for-ready.js'; @@ -83,7 +86,7 @@ export interface ConnectOrSpawnRuntimeHostInput { connectTimeoutMs?: number; handshakeTimeoutMs?: number; candidateEntrypoint: string | URL; - managedLaunchClaim?: RuntimeHostManagedLaunchClaim; + managedLaunchClaim?: RuntimeHostManagedOnDemandLaunchClaim; signal?: AbortSignal; /** Candidate-exit sink forwarded to the launcher; the embedder owns the sink. */ onExit?: (details: CandidateExitDetails) => void; @@ -95,6 +98,8 @@ interface ConnectOrSpawnRuntimeHostDependencies { /** Defaults to `process.env`; injected so tests never mutate the real environment. */ env?: NodeJS.ProcessEnv; connectHost?: typeof connectResolvedRuntimeHost; + /** Authority-location override for tests and embedded runtimes. */ + managedDeploymentAuthority?: RuntimeHostManagedDeploymentAuthorityOptions; } type ElectionConnectionResult = Awaited>; @@ -312,6 +317,9 @@ export async function connectOrSpawnRuntimeHostWithDependencies( input.managedLaunchClaim === undefined ? undefined : decodeRuntimeHostManagedLaunchClaim(input.managedLaunchClaim); + if (managedLaunchClaim && !isRuntimeHostManagedOnDemandLaunchClaim(managedLaunchClaim)) { + return { kind: 'failed', reason: 'deployment_lifecycle_mismatch' }; + } input.signal?.throwIfAborted(); const clientInstanceId = requireClientInstanceId(input.clientInstanceId ?? randomUUID()); const capability = await resolveStorageRoot({ path: input.rootPath, kind: 'interactive' }); @@ -424,9 +432,25 @@ export async function connectOrSpawnRuntimeHostWithDependencies( !candidateInFlight && now >= nextCandidateAt ) { + let managedDeployment; + try { + managedDeployment = await readRuntimeHostManagedDeploymentConfig( + capability, + dependencies.managedDeploymentAuthority, + ); + } catch (error) { + if ( + error instanceof RuntimeHostManagedDeploymentError && + error.code === 'invalid_config' + ) { + return { kind: 'failed', reason: 'deployment_record_invalid' }; + } + throw error; + } const managedLaunchRejection = runtimeHostManagedLaunchRejection( - await readRuntimeHostLifecycleFence(capability), + managedDeployment, managedLaunchClaim, + 'on_demand', ); if (managedLaunchRejection !== undefined) { return { kind: 'failed', reason: managedLaunchRejection }; diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index 1083512a28..bfdbf5cf6a 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -25,7 +25,7 @@ import { candidateStartupFailureForExitCode, type CandidateStartupFailureReport, } from '../candidate-startup-failure.js'; -import type { RuntimeHostManagedLaunchClaim } from '../operator/managed-deployment.js'; +import type { RuntimeHostManagedOnDemandLaunchClaim } from '../operator/managed-deployment.js'; import { RUNTIME_HOST_STDERR_PIPE_ENV } from '../process-diagnostics.js'; const CANDIDATE_STDERR_MAX_BYTES = 4 * 1024; @@ -43,7 +43,7 @@ export interface DetachedCandidateInput { initialConnectionTimeoutMs?: number; idleGraceMs?: number; handshakeTimeoutMs?: number; - managedLaunchClaim?: RuntimeHostManagedLaunchClaim; + managedLaunchClaim?: RuntimeHostManagedOnDemandLaunchClaim; executable?: string; entrypoint: string | URL; env?: NodeJS.ProcessEnv; @@ -142,9 +142,6 @@ function spawnCandidate( appendArgument(args, '--managed-deployment-id', input.managedLaunchClaim.deploymentId); appendArgument(args, '--managed-config-revision', input.managedLaunchClaim.configRevision); appendArgument(args, '--managed-lifecycle-mode', input.managedLaunchClaim.lifecycle.mode); - if (input.managedLaunchClaim.lifecycle.mode === 'supervised') { - appendArgument(args, '--managed-provider', input.managedLaunchClaim.lifecycle.provider); - } } // spawn() commits the side effect synchronously; spawned only reports that commit's outcome. diff --git a/packages/runtime-host/src/client/startup-error.ts b/packages/runtime-host/src/client/startup-error.ts index d1ad0408bd..ac6871c42d 100644 --- a/packages/runtime-host/src/client/startup-error.ts +++ b/packages/runtime-host/src/client/startup-error.ts @@ -77,25 +77,25 @@ export function runtimeHostStartupError( reason, 'This workspace is managed by a Runtime Host operator. Activate it through the configured Host profile. Diagnostic code: MANAGED_ROOT_REQUIRES_OPERATOR.', ); - case 'deployment_fence_missing': + case 'deployment_record_missing': return new RuntimeHostStartupError( reason, - 'The managed Runtime Host deployment is missing its State Root lifecycle fence. Repair the deployment before connecting. Diagnostic code: DEPLOYMENT_FENCE_MISSING.', + 'The Runtime Host operator refers to a managed deployment that is not installed. Repair the deployment before connecting. Diagnostic code: DEPLOYMENT_RECORD_MISSING.', ); - case 'deployment_fence_mismatch': + case 'deployment_claim_mismatch': return new RuntimeHostStartupError( reason, - 'The Runtime Host operator does not match the State Root lifecycle owner. Repair or explicitly migrate the deployment. Diagnostic code: DEPLOYMENT_FENCE_MISMATCH.', + 'The Runtime Host operator does not match the managed deployment. Repair or explicitly migrate the deployment. Diagnostic code: DEPLOYMENT_CLAIM_MISMATCH.', ); - case 'deployment_transition_in_progress': + case 'deployment_lifecycle_mismatch': return new RuntimeHostStartupError( reason, - 'The Runtime Host deployment is changing lifecycle owner. Retry after the operation completes. Diagnostic code: DEPLOYMENT_TRANSITION_IN_PROGRESS.', + 'The Runtime Host launch path cannot honor the configured lifecycle. Use the deployment operator. Diagnostic code: DEPLOYMENT_LIFECYCLE_MISMATCH.', ); - case 'deployment_needs_repair': + case 'deployment_record_invalid': return new RuntimeHostStartupError( reason, - 'The Runtime Host deployment could not prove a safe lifecycle owner. Run deployment repair before connecting. Diagnostic code: DEPLOYMENT_NEEDS_REPAIR.', + 'The Runtime Host managed deployment record is invalid. Run deployment repair before connecting. Diagnostic code: DEPLOYMENT_RECORD_INVALID.', ); case 'startup_timeout': return new Error( diff --git a/packages/runtime-host/src/control/startup-diagnostic.ts b/packages/runtime-host/src/control/startup-diagnostic.ts index cb910345dc..c906898716 100644 --- a/packages/runtime-host/src/control/startup-diagnostic.ts +++ b/packages/runtime-host/src/control/startup-diagnostic.ts @@ -25,6 +25,7 @@ import { redactSecrets } from '@maka/core/redaction'; import { resolveRootControlNamespace } from '@maka/storage/root-authority'; import { z } from 'zod'; import { + CANDIDATE_STARTUP_FAILURE_REASONS, isCandidateStartupAttemptId, type CandidateStartupFailure, } from '../candidate-startup-failure.js'; @@ -59,12 +60,7 @@ const candidateStartupDiagnosticSchema = z capturedAt: boundedStringSchema(MAX_LABEL_BYTES).refine((value) => Number.isFinite(Date.parse(value)), ), - reason: z.enum([ - 'stored_data_incompatible', - 'operational_state_migration_blocked', - 'local_ipc_security_failed', - 'internal_startup_failure', - ]), + reason: z.enum(CANDIDATE_STARTUP_FAILURE_REASONS), errorChain: z.array(candidateStartupErrorSummarySchema).min(1).max(MAX_ERROR_CHAIN_ENTRIES), logs: z.array(boundedStringSchema(MAX_LOG_TEXT_BYTES)).max(MAX_LOG_ENTRIES), }) diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 99bb093c60..3c7d5b2778 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -104,25 +104,26 @@ export { type LocalHostHandoffActiveWorkPolicy, } from './local-process-deployment-handoff.js'; export { - RUNTIME_HOST_LIFECYCLE_FENCE_FILE, RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE, RuntimeHostManagedDeploymentError, - assertRuntimeHostManagedLaunchAuthorized, - claimRuntimeHostLifecycleFence, - decodeRuntimeHostLifecycleFence, + claimRuntimeHostManagedDeployment, decodeRuntimeHostManagedDeploymentConfig, decodeRuntimeHostManagedLaunchClaim, - readRuntimeHostLifecycleFence, + isRuntimeHostManagedOnDemandLaunchClaim, readRuntimeHostManagedDeploymentConfig, - releaseRuntimeHostLifecycleFence, + resolveRuntimeHostManagedDeploymentAuthorityRoot, resolveRuntimeHostManagedDeploymentConfigPath, + runtimeHostManagedLaunchClaim, runtimeHostManagedLaunchRejection, - writeRuntimeHostManagedDeploymentConfig, - type RuntimeHostActiveLifecycleFence, - type RuntimeHostLifecycleFence, + tryAcquireRuntimeHostLaunchOwner, + type RuntimeHostManagedDeploymentAuthorityOptions, type RuntimeHostManagedDeploymentConfig, type RuntimeHostManagedLaunchClaim, type RuntimeHostManagedLaunchRejection, + type RuntimeHostManagedOnDemandDeploymentConfig, + type RuntimeHostManagedOnDemandLaunchClaim, + type RuntimeHostManagedSupervisedDeploymentConfig, + type RuntimeHostManagedSupervisedLaunchClaim, type RuntimeHostReconciliationProvider, type RuntimeHostSupervisorProvider, } from './managed-deployment.js'; diff --git a/packages/runtime-host/src/operator/managed-deployment.ts b/packages/runtime-host/src/operator/managed-deployment.ts index 303847ec9d..f22564da6c 100644 --- a/packages/runtime-host/src/operator/managed-deployment.ts +++ b/packages/runtime-host/src/operator/managed-deployment.ts @@ -18,21 +18,21 @@ */ import { randomUUID } from 'node:crypto'; -import { chmod, lstat, open, readFile, rename, rm, unlink } from 'node:fs/promises'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { userInfo } from 'node:os'; +import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; import { - prepareStorageRootControlDirectory, + type StateRootOwner, type StorageRootCapability, + tryAcquireStateRootOwner, } from '@maka/storage/root-authority'; import { withProcessLifetimeFileUpdateLock } from '@maka/storage/process-lifetime-file-update-lock'; +import { syncDirectory, syncDirectoryChain } from '@maka/storage/stable-storage'; import { z } from 'zod'; -import { - isRuntimeHostNpmDeploymentIdentity, - type RuntimeHostDeploymentIdentity, -} from './update-package-evidence.js'; +import { isProductReleaseVersion, isSha512PackageIntegrity } from './update-package-evidence.js'; export const RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE = 'runtime-host-deployment.json'; -export const RUNTIME_HOST_LIFECYCLE_FENCE_FILE = 'runtime-host-managed-owner.json'; const SCHEMA_VERSION = 1 as const; const MAX_DOCUMENT_BYTES = 64 * 1024; @@ -58,9 +58,13 @@ const reconciliationProviderSchema = z.enum([ 'launch_agent_timer', 'openrc_supervised_loop', ]); -const packageIdentitySchema = z.custom( - isRuntimeHostNpmDeploymentIdentity, -); +const packageIdentitySchema = z + .object({ + kind: z.literal('npm_registry'), + version: z.string().refine(isProductReleaseVersion), + integrity: z.string().refine(isSha512PackageIntegrity), + }) + .strict(); const lifecycleSchema = z.discriminatedUnion('mode', [ z @@ -78,24 +82,35 @@ const lifecycleSchema = z.discriminatedUnion('mode', [ .strict(), ]); -const reconciliationSchema = z.discriminatedUnion('policy', [ - z.object({ policy: z.literal('manual') }).strict(), +const launchLifecycleSchema = z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('on_demand') }).strict(), z .object({ - policy: z.literal('automatic'), - trigger: z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('activation') }).strict(), - z - .object({ - kind: z.literal('scheduled'), - provider: reconciliationProviderSchema, - }) - .strict(), - ]), + mode: z.literal('supervised'), + provider: providerSchema, }) .strict(), ]); +const reconciliationSchema = z.discriminatedUnion('trigger', [ + z.object({ trigger: z.literal('manual') }).strict(), + z.object({ trigger: z.literal('activation') }).strict(), + z + .object({ + trigger: z.literal('scheduled'), + provider: reconciliationProviderSchema, + }) + .strict(), +]); + +const managedLaunchClaimSchema = z + .object({ + deploymentId: deploymentIdSchema, + configRevision: configRevisionSchema, + lifecycle: launchLifecycleSchema, + }) + .strict(); + const managedDeploymentConfigSchema = z .object({ schemaVersion: z.literal(SCHEMA_VERSION), @@ -144,134 +159,83 @@ const managedDeploymentConfigSchema = z }) .strict() .superRefine((value, context) => { - if ( - value.lifecycle.mode === 'on_demand' && - value.reconciliation.policy === 'automatic' && - value.reconciliation.trigger.kind !== 'activation' - ) { + if (value.lifecycle.mode === 'on_demand' && value.reconciliation.trigger === 'scheduled') { context.addIssue({ code: 'custom', - message: 'An on-demand deployment can only reconcile during activation', + message: 'An on-demand deployment cannot use scheduled reconciliation', path: ['reconciliation'], }); } - if ( - value.lifecycle.mode === 'supervised' && - value.reconciliation.policy === 'automatic' && - value.reconciliation.trigger.kind === 'activation' - ) { + if (value.lifecycle.mode === 'supervised' && value.reconciliation.trigger === 'activation') { context.addIssue({ code: 'custom', - message: 'A supervised deployment requires a scheduled reconciliation trigger', + message: 'A supervised deployment cannot reconcile during Client activation', path: ['reconciliation'], }); } - if ( - value.lifecycle.mode === 'supervised' && - value.reconciliation.policy === 'automatic' && - value.reconciliation.trigger.kind === 'scheduled' - ) { + if (value.lifecycle.mode === 'supervised' && value.reconciliation.trigger === 'scheduled') { const expected = value.lifecycle.provider === 'systemd_user' ? 'systemd_timer' : value.lifecycle.provider === 'launch_agent' ? 'launch_agent_timer' : 'openrc_supervised_loop'; - if (value.reconciliation.trigger.provider !== expected) { + if (value.reconciliation.provider !== expected) { context.addIssue({ code: 'custom', message: 'The reconciliation trigger does not match the persisted supervisor provider', - path: ['reconciliation', 'trigger', 'provider'], + path: ['reconciliation', 'provider'], }); } } }); -const activeFenceStateSchema = z - .object({ - kind: z.literal('active'), - deploymentId: deploymentIdSchema, - configRevision: configRevisionSchema, - lifecycle: z.discriminatedUnion('mode', [ - z.object({ mode: z.literal('on_demand') }).strict(), - z - .object({ - mode: z.literal('supervised'), - provider: providerSchema, - }) - .strict(), - ]), - }) - .strict(); - -const lifecycleFenceSchema = z - .object({ - schemaVersion: z.literal(SCHEMA_VERSION), - rootId: z.string().regex(ROOT_ID_PATTERN), - revision: z.string().regex(UUID_PATTERN), - state: z.discriminatedUnion('kind', [ - activeFenceStateSchema, - z - .object({ - kind: z.literal('transition'), - deploymentId: deploymentIdSchema, - transactionId: deploymentIdSchema, - fromConfigRevision: configRevisionSchema.nullable(), - toConfigRevision: configRevisionSchema.nullable(), - }) - .strict(), - z - .object({ - kind: z.literal('blocked'), - deploymentId: deploymentIdSchema, - transactionId: deploymentIdSchema, - reasonCode: boundedText(256), - }) - .strict(), - ]), - }) - .strict(); - -const managedLaunchClaimSchema = z - .object({ - deploymentId: deploymentIdSchema, - configRevision: configRevisionSchema, - lifecycle: z.discriminatedUnion('mode', [ - z.object({ mode: z.literal('on_demand') }).strict(), - z - .object({ - mode: z.literal('supervised'), - provider: providerSchema, - }) - .strict(), - ]), - }) - .strict(); - export type RuntimeHostSupervisorProvider = z.infer; export type RuntimeHostReconciliationProvider = z.infer; export type RuntimeHostManagedDeploymentConfig = z.infer; -export type RuntimeHostLifecycleFence = z.infer; export type RuntimeHostManagedLaunchClaim = z.infer; -export type RuntimeHostActiveLifecycleFence = RuntimeHostLifecycleFence & { - readonly state: z.infer; +export type RuntimeHostManagedOnDemandDeploymentConfig = RuntimeHostManagedDeploymentConfig & { + readonly lifecycle: { readonly mode: 'on_demand'; readonly availability: 'activation' }; }; +export type RuntimeHostManagedSupervisedDeploymentConfig = RuntimeHostManagedDeploymentConfig & { + readonly lifecycle: { + readonly mode: 'supervised'; + readonly provider: RuntimeHostSupervisorProvider; + readonly availability: 'session' | 'environment' | 'machine'; + }; +}; +export type RuntimeHostManagedOnDemandLaunchClaim = RuntimeHostManagedLaunchClaim & { + readonly lifecycle: { readonly mode: 'on_demand' }; +}; +export type RuntimeHostManagedSupervisedLaunchClaim = RuntimeHostManagedLaunchClaim & { + readonly lifecycle: { + readonly mode: 'supervised'; + readonly provider: RuntimeHostSupervisorProvider; + }; +}; + +export interface RuntimeHostManagedDeploymentAuthorityOptions { + /** Test-only or embedding override. Production uses the account-local durable default. */ + readonly authorityRoot?: string; + readonly homeDir?: string; + readonly platform?: NodeJS.Platform; +} export type RuntimeHostManagedLaunchRejection = | 'managed_root_requires_operator' - | 'deployment_fence_missing' - | 'deployment_fence_mismatch' - | 'deployment_transition_in_progress' - | 'deployment_needs_repair'; + | 'deployment_record_missing' + | 'deployment_claim_mismatch' + | 'deployment_lifecycle_mismatch' + | 'deployment_record_invalid'; export class RuntimeHostManagedDeploymentError extends Error { constructor( readonly code: | 'invalid_config' - | 'invalid_fence' | 'deployment_io_failed' + | 'deployment_commit_unknown' | 'lifecycle_owner_exists' - | 'lifecycle_owner_changed' + | 'state_root_owned' | RuntimeHostManagedLaunchRejection, message: string, options?: ErrorOptions, @@ -295,188 +259,259 @@ export function decodeRuntimeHostManagedDeploymentConfig( } } -export function decodeRuntimeHostLifecycleFence( - value: unknown, - expectedRootId?: string, -): RuntimeHostLifecycleFence { - try { - const fence = lifecycleFenceSchema.parse(value); - if (expectedRootId !== undefined && fence.rootId !== expectedRootId) { - throw new Error('The lifecycle fence belongs to a different State Root'); - } - return fence; - } catch (error) { - throw new RuntimeHostManagedDeploymentError( - 'invalid_fence', - 'The Runtime Host lifecycle fence is invalid', - { cause: error }, - ); - } -} - export function decodeRuntimeHostManagedLaunchClaim(value: unknown): RuntimeHostManagedLaunchClaim { try { return managedLaunchClaimSchema.parse(value); } catch (error) { throw new RuntimeHostManagedDeploymentError( - 'deployment_fence_mismatch', + 'deployment_claim_mismatch', 'The Runtime Host managed launch claim is invalid', { cause: error }, ); } } -export function resolveRuntimeHostManagedDeploymentConfigPath(clientDataRoot: string): string { - if (!isAbsolute(clientDataRoot)) { +export function isRuntimeHostManagedOnDemandLaunchClaim( + claim: RuntimeHostManagedLaunchClaim, +): claim is RuntimeHostManagedOnDemandLaunchClaim { + return claim.lifecycle.mode === 'on_demand'; +} + +export function runtimeHostManagedLaunchClaim( + config: RuntimeHostManagedOnDemandDeploymentConfig, +): RuntimeHostManagedOnDemandLaunchClaim; +export function runtimeHostManagedLaunchClaim( + config: RuntimeHostManagedSupervisedDeploymentConfig, +): RuntimeHostManagedSupervisedLaunchClaim; +export function runtimeHostManagedLaunchClaim( + config: RuntimeHostManagedDeploymentConfig, +): RuntimeHostManagedLaunchClaim; +export function runtimeHostManagedLaunchClaim( + config: RuntimeHostManagedDeploymentConfig, +): RuntimeHostManagedLaunchClaim { + const canonical = decodeRuntimeHostManagedDeploymentConfig(config); + return { + deploymentId: canonical.deploymentId, + configRevision: canonical.configRevision, + lifecycle: + canonical.lifecycle.mode === 'on_demand' + ? { mode: 'on_demand' } + : { mode: 'supervised', provider: canonical.lifecycle.provider }, + }; +} + +export function resolveRuntimeHostManagedDeploymentAuthorityRoot( + options: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): string { + if (options.authorityRoot !== undefined) { + if (!isAbsolute(options.authorityRoot)) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host managed deployment authority root must be absolute', + ); + } + return resolve(options.authorityRoot); + } + const homeDir = options.homeDir ?? userInfo().homedir; + const platform = options.platform ?? process.platform; + const accountPath = platform === 'win32' ? win32 : posix; + if (!accountPath.isAbsolute(homeDir)) { throw new RuntimeHostManagedDeploymentError( 'invalid_config', - 'The Runtime Host client data root must be absolute', + 'The OS account home must be absolute', ); } - return join(resolve(clientDataRoot), RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE); -} - -export async function readRuntimeHostManagedDeploymentConfig( - path: string, -): Promise { - const value = await readBoundedJson(path, 'config'); - return value === undefined ? undefined : decodeRuntimeHostManagedDeploymentConfig(value); + const segments = + platform === 'darwin' + ? ['Library', 'Application Support', 'Maka', 'runtime-host-deployments'] + : platform === 'win32' + ? ['AppData', 'Local', 'Maka', 'runtime-host-deployments'] + : ['.local', 'share', 'Maka', 'runtime-host-deployments']; + return accountPath.join(accountPath.normalize(homeDir), ...segments); } -export async function writeRuntimeHostManagedDeploymentConfig( - path: string, - config: RuntimeHostManagedDeploymentConfig, -): Promise { - const canonical = decodeRuntimeHostManagedDeploymentConfig(config); - await withProcessLifetimeFileUpdateLock(path, () => writePrivateJson(path, canonical)); +export function resolveRuntimeHostManagedDeploymentConfigPath( + rootId: string, + options: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): string { + requireRootId(rootId); + return join( + resolveRuntimeHostManagedDeploymentAuthorityRoot(options), + rootId, + RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE, + ); } -export async function readRuntimeHostLifecycleFence( +export async function readRuntimeHostManagedDeploymentConfig( capability: StorageRootCapability<'interactive'>, -): Promise { - const { controlDirectory } = await prepareStorageRootControlDirectory(capability); + options: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): Promise { const value = await readBoundedJson( - join(controlDirectory, RUNTIME_HOST_LIFECYCLE_FENCE_FILE), - 'fence', + resolveRuntimeHostManagedDeploymentConfigPath(capability.rootId, options), ); - return value === undefined - ? undefined - : decodeRuntimeHostLifecycleFence(value, capability.rootId); + if (value === undefined) return undefined; + const config = decodeRuntimeHostManagedDeploymentConfig(value); + assertConfigTargetsCapability(config, capability); + return config; } -export async function claimRuntimeHostLifecycleFence( +export function claimRuntimeHostManagedDeployment( capability: StorageRootCapability<'interactive'>, - claim: RuntimeHostManagedLaunchClaim, + config: RuntimeHostManagedOnDemandDeploymentConfig, + options?: RuntimeHostManagedDeploymentAuthorityOptions, +): Promise<{ + readonly kind: 'applied' | 'unchanged'; + readonly config: RuntimeHostManagedOnDemandDeploymentConfig; + readonly claim: RuntimeHostManagedOnDemandLaunchClaim; +}>; +export function claimRuntimeHostManagedDeployment( + capability: StorageRootCapability<'interactive'>, + config: RuntimeHostManagedSupervisedDeploymentConfig, + options?: RuntimeHostManagedDeploymentAuthorityOptions, +): Promise<{ + readonly kind: 'applied' | 'unchanged'; + readonly config: RuntimeHostManagedSupervisedDeploymentConfig; + readonly claim: RuntimeHostManagedSupervisedLaunchClaim; +}>; +export function claimRuntimeHostManagedDeployment( + capability: StorageRootCapability<'interactive'>, + config: RuntimeHostManagedDeploymentConfig, + options?: RuntimeHostManagedDeploymentAuthorityOptions, +): Promise<{ + readonly kind: 'applied' | 'unchanged'; + readonly config: RuntimeHostManagedDeploymentConfig; + readonly claim: RuntimeHostManagedLaunchClaim; +}>; +export async function claimRuntimeHostManagedDeployment( + capability: StorageRootCapability<'interactive'>, + config: RuntimeHostManagedDeploymentConfig, + options: RuntimeHostManagedDeploymentAuthorityOptions = {}, ): Promise<{ readonly kind: 'applied' | 'unchanged'; - readonly fence: RuntimeHostLifecycleFence; + readonly config: RuntimeHostManagedDeploymentConfig; + readonly claim: RuntimeHostManagedLaunchClaim; }> { - const canonicalClaim = decodeRuntimeHostManagedLaunchClaim(claim); - const { controlDirectory } = await prepareStorageRootControlDirectory(capability); - const path = join(controlDirectory, RUNTIME_HOST_LIFECYCLE_FENCE_FILE); - return withProcessLifetimeFileUpdateLock(path, async () => { - const currentValue = await readBoundedJson(path, 'fence'); - const current = - currentValue === undefined - ? undefined - : decodeRuntimeHostLifecycleFence(currentValue, capability.rootId); - if (current !== undefined) { - if (current.state.kind === 'active' && sameManagedLaunch(current.state, canonicalClaim)) { - return { kind: 'unchanged', fence: current }; + const canonical = decodeRuntimeHostManagedDeploymentConfig(config); + assertConfigTargetsCapability(canonical, capability); + const authorityRoot = resolveRuntimeHostManagedDeploymentAuthorityRoot(options); + const path = resolveRuntimeHostManagedDeploymentConfigPath(capability.rootId, options); + await prepareAuthorityDirectory(dirname(path), authorityRoot); + return withProcessLifetimeFileUpdateLock( + path, + async () => { + const currentValue = await readBoundedJson(path); + if (currentValue !== undefined) { + const current = decodeRuntimeHostManagedDeploymentConfig(currentValue); + assertConfigTargetsCapability(current, capability); + if (isDeepStrictEqual(current, canonical)) { + return { + kind: 'unchanged', + config: current, + claim: runtimeHostManagedLaunchClaim(current), + }; + } + throw new RuntimeHostManagedDeploymentError( + 'lifecycle_owner_exists', + 'The State Root already has a managed deployment', + ); } - throw new RuntimeHostManagedDeploymentError( - 'lifecycle_owner_exists', - 'The State Root already has a managed lifecycle owner', - ); - } - const fence: RuntimeHostLifecycleFence = { - schemaVersion: SCHEMA_VERSION, - rootId: capability.rootId, - revision: randomUUID(), - state: { - kind: 'active', - ...canonicalClaim, - }, - }; - await writePrivateJson(path, fence); - return { kind: 'applied', fence }; - }); + const owner = await tryAcquireStateRootOwner(capability); + if (!owner) { + throw new RuntimeHostManagedDeploymentError( + 'state_root_owned', + 'The State Root must be retired before it can become managed', + ); + } + try { + await writePrivateJson(path, canonical); + } finally { + await owner.close(); + } + return { + kind: 'applied', + config: canonical, + claim: runtimeHostManagedLaunchClaim(canonical), + }; + }, + UPDATE_LOCK_TIMEOUT_MS, + ); } -export async function releaseRuntimeHostLifecycleFence( +export async function tryAcquireRuntimeHostLaunchOwner( capability: StorageRootCapability<'interactive'>, - expected: { - readonly revision: string; - readonly deploymentId: string; - readonly configRevision: number; - }, -): Promise<'released' | 'unchanged'> { - const { controlDirectory } = await prepareStorageRootControlDirectory(capability); - const path = join(controlDirectory, RUNTIME_HOST_LIFECYCLE_FENCE_FILE); - return withProcessLifetimeFileUpdateLock(path, async () => { - const currentValue = await readBoundedJson(path, 'fence'); - if (currentValue === undefined) return 'unchanged'; - const current = decodeRuntimeHostLifecycleFence(currentValue, capability.rootId); - if ( - current.revision !== expected.revision || - current.state.kind !== 'active' || - current.state.deploymentId !== expected.deploymentId || - current.state.configRevision !== expected.configRevision - ) { - throw new RuntimeHostManagedDeploymentError( - 'lifecycle_owner_changed', - 'The Runtime Host lifecycle owner changed before release', + expectedLifecycleMode: 'on_demand' | 'supervised', + claim: RuntimeHostManagedLaunchClaim | undefined, + options: RuntimeHostManagedDeploymentAuthorityOptions = {}, +): Promise | undefined> { + const canonicalClaim = + claim === undefined ? undefined : decodeRuntimeHostManagedLaunchClaim(claim); + const authorityRoot = resolveRuntimeHostManagedDeploymentAuthorityRoot(options); + const path = resolveRuntimeHostManagedDeploymentConfigPath(capability.rootId, options); + await prepareAuthorityDirectory(dirname(path), authorityRoot); + return withProcessLifetimeFileUpdateLock( + path, + async () => { + const configValue = await readBoundedJson(path); + let config: RuntimeHostManagedDeploymentConfig | undefined; + if (configValue !== undefined) { + try { + config = decodeRuntimeHostManagedDeploymentConfig(configValue); + assertConfigTargetsCapability(config, capability); + } catch (error) { + throw new RuntimeHostManagedDeploymentError( + 'deployment_record_invalid', + 'The Runtime Host managed deployment record is invalid', + { cause: error }, + ); + } + } + const rejection = runtimeHostManagedLaunchRejection( + config, + canonicalClaim, + expectedLifecycleMode, ); - } - await removePrivateJson(path); - return 'released'; - }); + if (rejection !== undefined) { + throw new RuntimeHostManagedDeploymentError( + rejection, + managedLaunchRejectionMessage(rejection), + ); + } + return tryAcquireStateRootOwner(capability); + }, + UPDATE_LOCK_TIMEOUT_MS, + ); } export function runtimeHostManagedLaunchRejection( - fence: RuntimeHostLifecycleFence | undefined, + config: RuntimeHostManagedDeploymentConfig | undefined, claim: RuntimeHostManagedLaunchClaim | undefined, + expectedLifecycleMode: 'on_demand' | 'supervised', ): RuntimeHostManagedLaunchRejection | undefined { - if (fence === undefined) return claim === undefined ? undefined : 'deployment_fence_missing'; - if (fence.state.kind === 'transition') return 'deployment_transition_in_progress'; - if (fence.state.kind === 'blocked') return 'deployment_needs_repair'; + if (config === undefined) return claim === undefined ? undefined : 'deployment_record_missing'; if (claim === undefined) return 'managed_root_requires_operator'; - return sameManagedLaunch(fence.state, claim) ? undefined : 'deployment_fence_mismatch'; -} - -export async function assertRuntimeHostManagedLaunchAuthorized( - capability: StorageRootCapability<'interactive'>, - claim: RuntimeHostManagedLaunchClaim | undefined, -): Promise { - const canonicalClaim = - claim === undefined ? undefined : decodeRuntimeHostManagedLaunchClaim(claim); - const rejection = runtimeHostManagedLaunchRejection( - await readRuntimeHostLifecycleFence(capability), - canonicalClaim, - ); - if (rejection !== undefined) { - throw new RuntimeHostManagedDeploymentError( - rejection, - managedLaunchRejectionMessage(rejection), - ); + if (!sameManagedLaunch(runtimeHostManagedLaunchClaim(config), claim)) { + return 'deployment_claim_mismatch'; } + return config.lifecycle.mode === expectedLifecycleMode + ? undefined + : 'deployment_lifecycle_mismatch'; } function sameManagedLaunch( - active: z.infer, + expected: RuntimeHostManagedLaunchClaim, claim: RuntimeHostManagedLaunchClaim, ): boolean { if ( - active.deploymentId !== claim.deploymentId || - active.configRevision !== claim.configRevision || - active.lifecycle.mode !== claim.lifecycle.mode + expected.deploymentId !== claim.deploymentId || + expected.configRevision !== claim.configRevision || + expected.lifecycle.mode !== claim.lifecycle.mode ) { return false; } return ( - active.lifecycle.mode !== 'supervised' || + expected.lifecycle.mode !== 'supervised' || (claim.lifecycle.mode === 'supervised' && - active.lifecycle.provider === claim.lifecycle.provider) + expected.lifecycle.provider === claim.lifecycle.provider) ); } @@ -484,48 +519,60 @@ function managedLaunchRejectionMessage(rejection: RuntimeHostManagedLaunchReject switch (rejection) { case 'managed_root_requires_operator': return 'The State Root is managed and must be activated through its operator'; - case 'deployment_fence_missing': - return 'The managed Runtime Host deployment has no lifecycle fence'; - case 'deployment_fence_mismatch': - return 'The Runtime Host launch does not match the active lifecycle owner'; - case 'deployment_transition_in_progress': - return 'The Runtime Host deployment is changing lifecycle owner'; - case 'deployment_needs_repair': - return 'The Runtime Host deployment requires repair before activation'; + case 'deployment_record_missing': + return 'The managed Runtime Host launch has no deployment record'; + case 'deployment_claim_mismatch': + return 'The Runtime Host launch does not match the managed deployment'; + case 'deployment_lifecycle_mismatch': + return 'The Runtime Host launch path cannot honor the configured lifecycle'; + case 'deployment_record_invalid': + return 'The Runtime Host managed deployment record is invalid'; } } -async function readBoundedJson( - path: string, - kind: 'config' | 'fence', -): Promise { +function assertConfigTargetsCapability( + config: RuntimeHostManagedDeploymentConfig, + capability: StorageRootCapability<'interactive'>, +): void { + if ( + config.root.id !== capability.rootId || + resolve(config.root.path) !== capability.canonicalPath + ) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host managed deployment targets a different State Root', + ); + } +} + +async function readBoundedJson(path: string): Promise { let target: Awaited>; try { target = await lstat(path); } catch (error) { if (isNodeError(error, 'ENOENT')) return undefined; - throw deploymentIo('Unable to inspect the Runtime Host managed deployment ' + kind, error); + throw deploymentIo('Unable to inspect the Runtime Host managed deployment record', error); } if (!target.isFile() || target.isSymbolicLink() || target.size > MAX_DOCUMENT_BYTES) { throw new RuntimeHostManagedDeploymentError( - kind === 'config' ? 'invalid_config' : 'invalid_fence', - 'The Runtime Host managed deployment ' + kind + ' must be a bounded regular file', + 'invalid_config', + 'The Runtime Host managed deployment record must be a bounded regular file', ); } try { const contents = await readFile(path, 'utf8'); if (Buffer.byteLength(contents, 'utf8') > MAX_DOCUMENT_BYTES) { throw new RuntimeHostManagedDeploymentError( - kind === 'config' ? 'invalid_config' : 'invalid_fence', - 'The Runtime Host managed deployment ' + kind + ' exceeds its size limit', + 'invalid_config', + 'The Runtime Host managed deployment record exceeds its size limit', ); } return JSON.parse(contents) as unknown; } catch (error) { if (error instanceof RuntimeHostManagedDeploymentError) throw error; throw new RuntimeHostManagedDeploymentError( - kind === 'config' ? 'invalid_config' : 'invalid_fence', - 'The Runtime Host managed deployment ' + kind + ' is not valid JSON', + 'invalid_config', + 'The Runtime Host managed deployment record is not valid JSON', { cause: error }, ); } @@ -536,7 +583,7 @@ async function writePrivateJson(path: string, value: unknown): Promise { if (Buffer.byteLength(contents, 'utf8') > MAX_DOCUMENT_BYTES) { throw new RuntimeHostManagedDeploymentError( 'deployment_io_failed', - 'The Runtime Host managed deployment document exceeds its size limit', + 'The Runtime Host managed deployment record exceeds its size limit', ); } const temporaryPath = path + '.' + process.pid + '.' + randomUUID() + '.tmp'; @@ -555,37 +602,51 @@ async function writePrivateJson(path: string, value: unknown): Promise { await syncDirectory(dirname(path)); } catch (error) { if (error instanceof RuntimeHostManagedDeploymentError) throw error; - throw deploymentIo('Unable to publish the Runtime Host managed deployment document', error); + if (published) { + throw new RuntimeHostManagedDeploymentError( + 'deployment_commit_unknown', + 'The Runtime Host managed deployment may have been persisted; re-read it before retrying', + { cause: error }, + ); + } + throw deploymentIo('Unable to publish the Runtime Host managed deployment record', error); } finally { if (!published) await rm(temporaryPath, { force: true }); } } -async function removePrivateJson(path: string): Promise { +async function prepareAuthorityDirectory(path: string, authorityRoot: string): Promise { try { - await unlink(path); - await syncDirectory(dirname(path)); + await mkdir(path, { recursive: true, mode: 0o700 }); + const metadata = await lstat(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error('Managed deployment authority path is not a directory'); + } + if (process.platform !== 'win32') { + if (typeof process.getuid === 'function' && metadata.uid !== process.getuid()) { + throw new Error('Managed deployment authority path belongs to a different user'); + } + await chmod(path, 0o700); + } + await syncDirectoryChain(path, dirname(authorityRoot)); } catch (error) { - if (isNodeError(error, 'ENOENT')) return; - throw deploymentIo('Unable to remove the Runtime Host lifecycle fence', error); + throw deploymentIo('Unable to prepare the Runtime Host managed deployment authority', error); } } -async function syncDirectory(path: string): Promise { - const directory = await open(path, 'r'); - try { - await directory.sync(); - } finally { - await directory.close(); +function requireRootId(rootId: string): void { + if (!ROOT_ID_PATTERN.test(rootId)) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host State Root ID is invalid', + ); } } function deploymentIo(message: string, cause: unknown): RuntimeHostManagedDeploymentError { return cause instanceof RuntimeHostManagedDeploymentError ? cause - : new RuntimeHostManagedDeploymentError('deployment_io_failed', message, { - cause, - }); + : new RuntimeHostManagedDeploymentError('deployment_io_failed', message, { cause }); } function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { diff --git a/packages/runtime-host/src/server/candidate.ts b/packages/runtime-host/src/server/candidate.ts index ac38a681cf..188b7c2afd 100644 --- a/packages/runtime-host/src/server/candidate.ts +++ b/packages/runtime-host/src/server/candidate.ts @@ -17,10 +17,11 @@ * under the License. */ -import { resolveExistingStorageRoot, tryAcquireStateRootOwner } from '@maka/storage/root-authority'; +import { resolveExistingStorageRoot } from '@maka/storage/root-authority'; import { - assertRuntimeHostManagedLaunchAuthorized, - type RuntimeHostManagedLaunchClaim, + tryAcquireRuntimeHostLaunchOwner, + type RuntimeHostManagedDeploymentAuthorityOptions, + type RuntimeHostManagedOnDemandLaunchClaim, } from '../operator/managed-deployment.js'; import type { RuntimeHostCompositionSource } from './host-composition.js'; import { RuntimeHostKernel } from './host-kernel.js'; @@ -32,7 +33,12 @@ export interface InteractiveRuntimeHostCandidateOptions { idleGraceMs?: number; handshakeTimeoutMs?: number; generation?: string; - managedLaunchClaim?: RuntimeHostManagedLaunchClaim; + managedLaunchClaim?: RuntimeHostManagedOnDemandLaunchClaim; +} + +export interface InteractiveRuntimeHostCandidateDependencies { + /** Test-only authority-location override. */ + readonly managedDeploymentAuthority?: RuntimeHostManagedDeploymentAuthorityOptions; } export type InteractiveRuntimeHostCandidateResult = @@ -42,14 +48,19 @@ export type InteractiveRuntimeHostCandidateResult = export async function startInteractiveRuntimeHostCandidate( options: InteractiveRuntimeHostCandidateOptions, composition: RuntimeHostCompositionSource, + dependencies: InteractiveRuntimeHostCandidateDependencies = {}, ): Promise { const capability = await resolveExistingStorageRoot({ path: options.rootPath, kind: 'interactive', expectedRootId: options.expectedRootId, }); - await assertRuntimeHostManagedLaunchAuthorized(capability, options.managedLaunchClaim); - const owner = await tryAcquireStateRootOwner(capability); + const owner = await tryAcquireRuntimeHostLaunchOwner( + capability, + 'on_demand', + options.managedLaunchClaim, + dependencies.managedDeploymentAuthority, + ); if (!owner) return { kind: 'loser' }; const host = await RuntimeHostKernel.start({ owner, diff --git a/packages/runtime-host/src/server/execution-service.ts b/packages/runtime-host/src/server/execution-service.ts index 316e57e8b2..1669bf2c0b 100644 --- a/packages/runtime-host/src/server/execution-service.ts +++ b/packages/runtime-host/src/server/execution-service.ts @@ -17,14 +17,15 @@ * under the License. */ -import { resolveStorageRoot, tryAcquireStateRootOwner } from '@maka/storage/root-authority'; +import { resolveStorageRoot } from '@maka/storage/root-authority'; import { createExecutionRuntimeHostCompositionSource, type ExecutionRuntimeHostCompositionDependencies, } from './execution-composition-factory.js'; import { - assertRuntimeHostManagedLaunchAuthorized, - type RuntimeHostManagedLaunchClaim, + tryAcquireRuntimeHostLaunchOwner, + type RuntimeHostManagedDeploymentAuthorityOptions, + type RuntimeHostManagedSupervisedLaunchClaim, } from '../operator/managed-deployment.js'; import { RuntimeHostKernel } from './host-kernel.js'; import { openRuntimeHostAccessAuthority } from './access-authority.js'; @@ -38,7 +39,7 @@ export interface ExecutionRuntimeHostServiceOptions { readonly projectDirectoryRoots?: readonly PublishedProjectDirectoryRoot[]; readonly handshakeTimeoutMs?: number; readonly shutdownGraceMs?: number; - readonly managedLaunchClaim?: RuntimeHostManagedLaunchClaim; + readonly managedLaunchClaim?: RuntimeHostManagedSupervisedLaunchClaim; readonly websocket?: Omit< StartRuntimeHostWebSocketListenerOptions, 'accessAuthority' | 'accept' | 'isReady' @@ -46,7 +47,11 @@ export interface ExecutionRuntimeHostServiceOptions { readonly peer?: Omit; } -export type ExecutionRuntimeHostServiceDependencies = ExecutionRuntimeHostCompositionDependencies; +export interface ExecutionRuntimeHostServiceDependencies + extends ExecutionRuntimeHostCompositionDependencies { + /** Test-only authority-location override. */ + readonly managedDeploymentAuthority?: RuntimeHostManagedDeploymentAuthorityOptions; +} export class RuntimeHostRootAlreadyOwnedError extends Error { readonly code = 'root_already_owned'; @@ -63,8 +68,12 @@ export async function startExecutionRuntimeHostService( ): Promise { const composition = await createExecutionRuntimeHostCompositionSource(options, dependencies); const capability = await resolveStorageRoot({ path: options.rootPath, kind: 'interactive' }); - await assertRuntimeHostManagedLaunchAuthorized(capability, options.managedLaunchClaim); - const owner = await tryAcquireStateRootOwner(capability); + const owner = await tryAcquireRuntimeHostLaunchOwner( + capability, + 'supervised', + options.managedLaunchClaim, + dependencies.managedDeploymentAuthority, + ); if (!owner) throw new RuntimeHostRootAlreadyOwnedError(capability.canonicalPath); try { const accessAuthority = await openRuntimeHostAccessAuthority(owner.controlDirectory); From ce9e5800b441509ab705f2f00aa1e93acf5bb2be Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Thu, 27 Aug 2026 16:52:16 +0800 Subject: [PATCH 03/11] fix(runtime-host): make root ownership durable Generated-by: OpenAI Codex --- .../src/__tests__/candidate-cli.test.ts | 11 +- .../src/__tests__/host-kernel.test.ts | 55 ++- .../src/__tests__/managed-deployment.test.ts | 160 ++++++++- packages/runtime-host/src/candidate-cli.ts | 21 +- .../src/candidate-startup-failure.ts | 27 +- .../src/client/connect-or-spawn.ts | 15 +- packages/runtime-host/src/client/launcher.ts | 5 +- .../runtime-host/src/client/startup-error.ts | 13 +- packages/runtime-host/src/operator/index.ts | 4 +- .../src/operator/managed-deployment.ts | 333 +++++++++--------- packages/runtime-host/src/server/candidate.ts | 4 +- .../src/server/execution-service.ts | 4 +- .../fixtures/control-directory-hygiene.ts | 11 +- .../src/__tests__/root-authority.test.ts | 24 +- packages/storage/src/root-authority.ts | 126 +++++-- packages/storage/src/stable-storage.ts | 7 +- 16 files changed, 534 insertions(+), 286 deletions(-) diff --git a/packages/runtime-host/src/__tests__/candidate-cli.test.ts b/packages/runtime-host/src/__tests__/candidate-cli.test.ts index 64a4f996ce..19c20cc785 100644 --- a/packages/runtime-host/src/__tests__/candidate-cli.test.ts +++ b/packages/runtime-host/src/__tests__/candidate-cli.test.ts @@ -42,7 +42,7 @@ test('parses the production candidate flags', () => { assert.equal(parsed.idleGraceMs, 10_000); }); -test('parses a complete managed on-demand launch claim', () => { +test('parses a complete managed launch claim', () => { const parsed = parseInteractiveRuntimeHostCandidateArguments([ '--root', '/tmp/workspace', @@ -54,18 +54,15 @@ test('parses a complete managed on-demand launch claim', () => { DEPLOYMENT_ID, '--managed-config-revision', '7', - '--managed-lifecycle-mode', - 'on_demand', ]); assert.deepEqual(parsed.managedLaunchClaim, { deploymentId: DEPLOYMENT_ID, configRevision: 7, - lifecycle: { mode: 'on_demand' }, }); }); -test('rejects partial or contradictory managed launch claims', () => { +test('rejects partial or retired managed launch fields', () => { assert.throws( () => parseInteractiveRuntimeHostCandidateArguments([ @@ -95,10 +92,8 @@ test('rejects partial or contradictory managed launch claims', () => { '7', '--managed-lifecycle-mode', 'on_demand', - '--managed-provider', - 'systemd_user', ]), - /Invalid Runtime Host candidate argument: --managed-provider/u, + /Invalid Runtime Host candidate argument: --managed-lifecycle-mode/u, ); }); diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 3d66038ae8..cbb1f6d98e 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -59,8 +59,13 @@ import { readCandidateStartupDiagnostic, writeCandidateStartupDiagnostic, } from '../control/startup-diagnostic.js'; +import { + candidateStartupFailureExitCode, + classifyCandidateStartupFailure, +} from '../candidate-startup-failure.js'; import { claimRuntimeHostManagedDeployment, + resolveRuntimeHostManagedDeploymentConfigPath, type RuntimeHostManagedOnDemandDeploymentConfig, } from '../operator/managed-deployment.js'; import { removePosixEndpointDirectories } from './fixtures/endpoint-hygiene.js'; @@ -165,7 +170,10 @@ describe('non-serving Runtime Host kernel', () => { test('a managed State Root refuses an ordinary candidate launch before election', async () => { await withHostPaths(async (paths) => { const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); - const managedDeploymentAuthority = { authorityRoot: join(paths.base, 'managed-authority') }; + const managedDeploymentAuthority = { + authorityRoot: join(paths.base, 'managed-authority'), + durabilityBoundary: paths.base, + }; await claimRuntimeHostManagedDeployment( capability, managedDeploymentConfig(capability), @@ -207,7 +215,10 @@ describe('non-serving Runtime Host kernel', () => { test('a matching managed claim reaches the existing candidate election', async () => { await withHostPaths(async (paths) => { const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); - const managedDeploymentAuthority = { authorityRoot: join(paths.base, 'managed-authority') }; + const managedDeploymentAuthority = { + authorityRoot: join(paths.base, 'managed-authority'), + durabilityBoundary: paths.base, + }; const { claim } = await claimRuntimeHostManagedDeployment( capability, managedDeploymentConfig(capability), @@ -245,6 +256,46 @@ describe('non-serving Runtime Host kernel', () => { }); }); + test('a malformed managed record crosses the Candidate boundary as exit 84', async () => { + await withHostPaths(async (paths) => { + const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); + const managedDeploymentAuthority = { + authorityRoot: join(paths.base, 'managed-authority'), + durabilityBoundary: paths.base, + }; + const { claim } = await claimRuntimeHostManagedDeployment( + capability, + managedDeploymentConfig(capability), + managedDeploymentAuthority, + ); + await writeFile( + resolveRuntimeHostManagedDeploymentConfigPath( + capability.rootId, + managedDeploymentAuthority, + ), + '{not-json', + ); + + await assert.rejects( + startInteractiveRuntimeHostCandidate( + { + rootPath: capability.canonicalPath, + expectedRootId: capability.rootId, + managedLaunchClaim: claim, + }, + KERNEL_COMPOSITION, + { managedDeploymentAuthority }, + ), + (error: unknown) => { + const failure = classifyCandidateStartupFailure(error); + assert.deepEqual(failure, { reason: 'deployment_record_invalid' }); + assert.equal(candidateStartupFailureExitCode(failure), 84); + return true; + }, + ); + }); + }); + test('reports a recovery failure when the election produces no ready Host', async () => { await withHostPaths(async (paths) => { const result = await connectOrSpawnRuntimeHostWithDependencies( diff --git a/packages/runtime-host/src/__tests__/managed-deployment.test.ts b/packages/runtime-host/src/__tests__/managed-deployment.test.ts index 19a2a5b157..8f8c582b3c 100644 --- a/packages/runtime-host/src/__tests__/managed-deployment.test.ts +++ b/packages/runtime-host/src/__tests__/managed-deployment.test.ts @@ -18,11 +18,17 @@ */ import assert from 'node:assert/strict'; -import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import test from 'node:test'; -import { resolveStorageRoot, tryAcquireStateRootOwner } from '@maka/storage/root-authority'; +import { + resolveRootControlNamespace, + resolveRootOwnershipNamespace, + resolveStorageRoot, + tryAcquireStateRootOwner, +} from '@maka/storage/root-authority'; +import { classifyCandidateStartupFailure } from '../candidate-startup-failure.js'; import { RuntimeHostManagedDeploymentError, claimRuntimeHostManagedDeployment, @@ -53,9 +59,18 @@ async function fixture(t: test.TestContext): Promise { t.after(() => rm(rootPath, { recursive: true, force: true })); t.after(() => rm(authorityRoot, { recursive: true, force: true })); const capability = await resolveStorageRoot({ path: rootPath, kind: 'interactive' }); + t.after(() => + Promise.all([ + rm(join(resolveRootControlNamespace(), capability.rootId), { + recursive: true, + force: true, + }), + rm(join(resolveRootOwnershipNamespace(), `${capability.rootId}.lock`), { force: true }), + ]), + ); return { capability, - authority: { authorityRoot }, + authority: { authorityRoot, durabilityBoundary: authorityRoot }, config: createConfig(capability.canonicalPath, capability.rootId), }; } @@ -272,9 +287,12 @@ test('concurrent install and unmanaged launch cannot both cross the authority bo await launchOwner?.close(); if (claimSucceeded) { - assert.equal(launchResult.status, 'rejected'); - assert.ok(launchResult.reason instanceof RuntimeHostManagedDeploymentError); - assert.equal(launchResult.reason.code, 'managed_root_requires_operator'); + if (launchResult.status === 'rejected') { + assert.ok(launchResult.reason instanceof RuntimeHostManagedDeploymentError); + assert.equal(launchResult.reason.code, 'managed_root_requires_operator'); + } else { + assert.equal(launchResult.value, undefined); + } } else { assert.equal(claimResult.status, 'rejected'); assert.ok(claimResult.reason instanceof RuntimeHostManagedDeploymentError); @@ -298,6 +316,31 @@ test('concurrent managed activations elect exactly one State Root owner', async await Promise.all(owners.map((owner) => owner?.close())); }); +test('managed ownership survives deletion of the disposable control cache', { + skip: process.platform === 'win32', +}, async (t) => { + const input = await fixture(t); + const { claim } = await claimRuntimeHostManagedDeployment( + input.capability, + input.config, + input.authority, + ); + const owner = await tryAcquireRuntimeHostLaunchOwner( + input.capability, + 'on_demand', + claim, + input.authority, + ); + assert.ok(owner); + await rm(owner.controlDirectory, { recursive: true, force: true }); + + assert.equal( + await tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', claim, input.authority), + undefined, + ); + await owner.close(); +}); + test('maps missing and stale claims to fail-closed launch decisions', () => { const config = createConfig('/srv/maka/state', 'a'.repeat(64)); const claim = runtimeHostManagedLaunchClaim(config); @@ -336,3 +379,108 @@ test('rejects oversized deployment records before parsing', async (t) => { error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', ); }); + +test('normalizes unreadable deployment records at the launch authority boundary', async (t) => { + const input = await fixture(t); + const path = resolveRuntimeHostManagedDeploymentConfigPath( + input.capability.rootId, + input.authority, + ); + await mkdir(path, { recursive: true }); + + await assert.rejects( + tryAcquireRuntimeHostLaunchOwner( + input.capability, + 'on_demand', + runtimeHostManagedLaunchClaim(input.config), + input.authority, + ), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && + error.code === 'deployment_record_invalid', + ); +}); + +test('keeps transient deployment record I/O retryable at the Candidate boundary', { + skip: process.platform === 'win32', +}, async (t) => { + const input = await fixture(t); + const { claim } = await claimRuntimeHostManagedDeployment( + input.capability, + input.config, + input.authority, + ); + const path = resolveRuntimeHostManagedDeploymentConfigPath( + input.capability.rootId, + input.authority, + ); + await chmod(path, 0o000); + + await assert.rejects( + tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', claim, input.authority), + (error: unknown) => { + assert.ok(error instanceof RuntimeHostManagedDeploymentError); + assert.equal(error.code, 'deployment_io_failed'); + assert.deepEqual(classifyCandidateStartupFailure(error), { + reason: 'internal_startup_failure', + }); + return true; + }, + ); +}); + +test('concurrent first claims cannot adopt an unsynced directory as their durability boundary', async (t) => { + const input = await fixture(t); + const authorityBase = await mkdtemp(join(tmpdir(), 'maka-managed-durability-')); + t.after(() => rm(authorityBase, { recursive: true, force: true })); + let reportDirectoriesCreated!: () => void; + const directoriesCreated = new Promise((resolve) => { + reportDirectoriesCreated = resolve; + }); + let resumeFirst!: () => void; + const firstMayContinue = new Promise((resolve) => { + resumeFirst = resolve; + }); + let firstSync = true; + + const firstClaim = claimRuntimeHostManagedDeployment(input.capability, input.config, { + homeDir: authorityBase, + beforeDirectorySync: async (path) => { + if (firstSync) { + firstSync = false; + reportDirectoriesCreated(); + await firstMayContinue; + } + if (path === authorityBase) throw new Error('injected first directory sync failure'); + }, + }); + await directoriesCreated; + + try { + await assert.rejects( + claimRuntimeHostManagedDeployment(input.capability, input.config, { + homeDir: authorityBase, + beforeDirectorySync: (path) => { + if (path === authorityBase) { + throw new Error('injected concurrent directory sync failure'); + } + }, + }), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && error.code === 'deployment_io_failed', + ); + } finally { + resumeFirst(); + } + await assert.rejects( + firstClaim, + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && error.code === 'deployment_io_failed', + ); + assert.equal( + await readRuntimeHostManagedDeploymentConfig(input.capability, { + homeDir: authorityBase, + }), + undefined, + ); +}); diff --git a/packages/runtime-host/src/candidate-cli.ts b/packages/runtime-host/src/candidate-cli.ts index 8e298f6485..cc369a83a4 100644 --- a/packages/runtime-host/src/candidate-cli.ts +++ b/packages/runtime-host/src/candidate-cli.ts @@ -21,8 +21,7 @@ import type { InteractiveRuntimeHostCandidateOptions } from './server/candidate. import { isCandidateStartupAttemptId } from './candidate-startup-failure.js'; import { decodeRuntimeHostManagedLaunchClaim, - isRuntimeHostManagedOnDemandLaunchClaim, - type RuntimeHostManagedOnDemandLaunchClaim, + type RuntimeHostManagedLaunchClaim, } from './operator/managed-deployment.js'; export interface ParsedInteractiveRuntimeHostCandidateArguments @@ -43,7 +42,6 @@ export function parseInteractiveRuntimeHostCandidateArguments( 'generation', 'managed-deployment-id', 'managed-config-revision', - 'managed-lifecycle-mode', ]); const values = new Map(); for (let index = 0; index < args.length; index += 2) { @@ -83,30 +81,21 @@ export function parseInteractiveRuntimeHostCandidateArguments( function readManagedLaunchClaim( values: ReadonlyMap, -): RuntimeHostManagedOnDemandLaunchClaim | undefined { +): RuntimeHostManagedLaunchClaim | undefined { const deploymentId = values.get('managed-deployment-id'); const rawRevision = values.get('managed-config-revision'); - const lifecycleMode = values.get('managed-lifecycle-mode'); - if (deploymentId === undefined && rawRevision === undefined && lifecycleMode === undefined) { - return undefined; - } - if (deploymentId === undefined || rawRevision === undefined || lifecycleMode === undefined) { + if (deploymentId === undefined && rawRevision === undefined) return undefined; + if (deploymentId === undefined || rawRevision === undefined) { throw new Error('Runtime Host candidate requires a complete managed launch claim'); } const configRevision = Number(rawRevision); if (!Number.isSafeInteger(configRevision) || configRevision <= 0) { throw new Error('Invalid --managed-config-revision'); } - if (lifecycleMode !== 'on_demand') throw new Error('Invalid --managed-lifecycle-mode'); - const claim = decodeRuntimeHostManagedLaunchClaim({ + return decodeRuntimeHostManagedLaunchClaim({ deploymentId, configRevision, - lifecycle: { mode: lifecycleMode }, }); - if (!isRuntimeHostManagedOnDemandLaunchClaim(claim)) { - throw new Error('Invalid --managed-lifecycle-mode'); - } - return claim; } function readGeneration(values: Map): string { diff --git a/packages/runtime-host/src/candidate-startup-failure.ts b/packages/runtime-host/src/candidate-startup-failure.ts index dbf330a35c..c38d37007a 100644 --- a/packages/runtime-host/src/candidate-startup-failure.ts +++ b/packages/runtime-host/src/candidate-startup-failure.ts @@ -17,19 +17,21 @@ * under the License. */ +import { RUNTIME_HOST_MANAGED_LAUNCH_REJECTIONS } from './operator/managed-deployment.js'; + export const CANDIDATE_STARTUP_FAILURE_REASONS = [ 'stored_data_incompatible', 'operational_state_migration_blocked', 'local_ipc_security_failed', 'internal_startup_failure', - 'managed_root_requires_operator', - 'deployment_record_missing', - 'deployment_claim_mismatch', - 'deployment_lifecycle_mismatch', - 'deployment_record_invalid', + ...RUNTIME_HOST_MANAGED_LAUNCH_REJECTIONS, ] as const; export type CandidateStartupFailureReason = (typeof CANDIDATE_STARTUP_FAILURE_REASONS)[number]; +export type PermanentCandidateStartupFailureReason = Exclude< + CandidateStartupFailureReason, + 'local_ipc_security_failed' | 'internal_startup_failure' +>; export interface CandidateStartupFailure { readonly reason: CandidateStartupFailureReason; @@ -42,14 +44,6 @@ export interface CandidateStartupFailureReport extends CandidateStartupFailure { const STARTUP_ATTEMPT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; -const MANAGED_AUTHORITY_FAILURES = [ - 'managed_root_requires_operator', - 'deployment_record_missing', - 'deployment_claim_mismatch', - 'deployment_lifecycle_mismatch', - 'deployment_record_invalid', -] as const; - const EXIT_CODE_BY_REASON: Readonly> = { stored_data_incompatible: 65, operational_state_migration_blocked: 78, @@ -73,7 +67,7 @@ export function classifyCandidateStartupFailure(error: unknown): CandidateStartu if (errors.some((candidate) => errorCode(candidate) === 'insecure_endpoint_directory')) { return { reason: 'local_ipc_security_failed' }; } - for (const reason of MANAGED_AUTHORITY_FAILURES) { + for (const reason of RUNTIME_HOST_MANAGED_LAUNCH_REJECTIONS) { if (errors.some((candidate) => errorCode(candidate) === reason)) return { reason }; } return { reason: 'internal_startup_failure' }; @@ -82,10 +76,7 @@ export function classifyCandidateStartupFailure(error: unknown): CandidateStartu export function isPermanentCandidateStartupFailure( failure: CandidateStartupFailure | undefined, ): failure is CandidateStartupFailure & { - readonly reason: Exclude< - CandidateStartupFailureReason, - 'local_ipc_security_failed' | 'internal_startup_failure' - >; + readonly reason: PermanentCandidateStartupFailureReason; } { return ( failure !== undefined && diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts index 02c7b84388..97649387f8 100644 --- a/packages/runtime-host/src/client/connect-or-spawn.ts +++ b/packages/runtime-host/src/client/connect-or-spawn.ts @@ -59,13 +59,11 @@ import { } from '../control/startup-diagnostic.js'; import { decodeRuntimeHostManagedLaunchClaim, - isRuntimeHostManagedOnDemandLaunchClaim, readRuntimeHostManagedDeploymentConfig, runtimeHostManagedLaunchRejection, RuntimeHostManagedDeploymentError, type RuntimeHostManagedDeploymentAuthorityOptions, - type RuntimeHostManagedOnDemandLaunchClaim, - type RuntimeHostManagedLaunchRejection, + type RuntimeHostManagedLaunchClaim, } from '../operator/managed-deployment.js'; import { abortable, waitForRuntimeHostReady } from './wait-for-ready.js'; @@ -86,7 +84,7 @@ export interface ConnectOrSpawnRuntimeHostInput { connectTimeoutMs?: number; handshakeTimeoutMs?: number; candidateEntrypoint: string | URL; - managedLaunchClaim?: RuntimeHostManagedOnDemandLaunchClaim; + managedLaunchClaim?: RuntimeHostManagedLaunchClaim; signal?: AbortSignal; /** Candidate-exit sink forwarded to the launcher; the embedder owns the sink. */ onExit?: (details: CandidateExitDetails) => void; @@ -146,11 +144,7 @@ export type ConnectOrSpawnRuntimeHostResult = } | { kind: 'failed'; - reason: - | CandidateStartupFailure['reason'] - | RuntimeHostManagedLaunchRejection - | 'startup_timeout' - | 'host_unresponsive'; + reason: CandidateStartupFailure['reason'] | 'startup_timeout' | 'host_unresponsive'; diagnostic?: RuntimeHostElectionDiagnostic; }; @@ -317,9 +311,6 @@ export async function connectOrSpawnRuntimeHostWithDependencies( input.managedLaunchClaim === undefined ? undefined : decodeRuntimeHostManagedLaunchClaim(input.managedLaunchClaim); - if (managedLaunchClaim && !isRuntimeHostManagedOnDemandLaunchClaim(managedLaunchClaim)) { - return { kind: 'failed', reason: 'deployment_lifecycle_mismatch' }; - } input.signal?.throwIfAborted(); const clientInstanceId = requireClientInstanceId(input.clientInstanceId ?? randomUUID()); const capability = await resolveStorageRoot({ path: input.rootPath, kind: 'interactive' }); diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index bfdbf5cf6a..32c7ae926d 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -25,7 +25,7 @@ import { candidateStartupFailureForExitCode, type CandidateStartupFailureReport, } from '../candidate-startup-failure.js'; -import type { RuntimeHostManagedOnDemandLaunchClaim } from '../operator/managed-deployment.js'; +import type { RuntimeHostManagedLaunchClaim } from '../operator/managed-deployment.js'; import { RUNTIME_HOST_STDERR_PIPE_ENV } from '../process-diagnostics.js'; const CANDIDATE_STDERR_MAX_BYTES = 4 * 1024; @@ -43,7 +43,7 @@ export interface DetachedCandidateInput { initialConnectionTimeoutMs?: number; idleGraceMs?: number; handshakeTimeoutMs?: number; - managedLaunchClaim?: RuntimeHostManagedOnDemandLaunchClaim; + managedLaunchClaim?: RuntimeHostManagedLaunchClaim; executable?: string; entrypoint: string | URL; env?: NodeJS.ProcessEnv; @@ -141,7 +141,6 @@ function spawnCandidate( if (input.managedLaunchClaim !== undefined) { appendArgument(args, '--managed-deployment-id', input.managedLaunchClaim.deploymentId); appendArgument(args, '--managed-config-revision', input.managedLaunchClaim.configRevision); - appendArgument(args, '--managed-lifecycle-mode', input.managedLaunchClaim.lifecycle.mode); } // spawn() commits the side effect synchronously; spawned only reports that commit's outcome. diff --git a/packages/runtime-host/src/client/startup-error.ts b/packages/runtime-host/src/client/startup-error.ts index ac6871c42d..46a535fc85 100644 --- a/packages/runtime-host/src/client/startup-error.ts +++ b/packages/runtime-host/src/client/startup-error.ts @@ -17,14 +17,15 @@ * under the License. */ -import type { CandidateStartupFailureReason } from '../candidate-startup-failure.js'; -import type { RuntimeHostManagedLaunchRejection } from '../operator/managed-deployment.js'; +import type { + CandidateStartupFailureReason, + PermanentCandidateStartupFailureReason, +} from '../candidate-startup-failure.js'; import type { RuntimeHostElectionDiagnostic } from './connect-or-spawn.js'; import { RuntimeHostPermanentReconnectError } from './reconnect-lifecycle.js'; export type RuntimeHostStartupFailureReason = | CandidateStartupFailureReason - | RuntimeHostManagedLaunchRejection | 'composition_mismatch' | 'startup_timeout' | 'host_unresponsive'; @@ -33,11 +34,7 @@ export class RuntimeHostStartupError extends RuntimeHostPermanentReconnectError readonly name = 'RuntimeHostStartupError'; constructor( - readonly reason: - | 'stored_data_incompatible' - | 'operational_state_migration_blocked' - | 'composition_mismatch' - | RuntimeHostManagedLaunchRejection, + readonly reason: PermanentCandidateStartupFailureReason | 'composition_mismatch', message: string, ) { super(message); diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 3c7d5b2778..19dd9eb0d9 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -105,11 +105,11 @@ export { } from './local-process-deployment-handoff.js'; export { RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE, + RUNTIME_HOST_MANAGED_LAUNCH_REJECTIONS, RuntimeHostManagedDeploymentError, claimRuntimeHostManagedDeployment, decodeRuntimeHostManagedDeploymentConfig, decodeRuntimeHostManagedLaunchClaim, - isRuntimeHostManagedOnDemandLaunchClaim, readRuntimeHostManagedDeploymentConfig, resolveRuntimeHostManagedDeploymentAuthorityRoot, resolveRuntimeHostManagedDeploymentConfigPath, @@ -121,9 +121,7 @@ export { type RuntimeHostManagedLaunchClaim, type RuntimeHostManagedLaunchRejection, type RuntimeHostManagedOnDemandDeploymentConfig, - type RuntimeHostManagedOnDemandLaunchClaim, type RuntimeHostManagedSupervisedDeploymentConfig, - type RuntimeHostManagedSupervisedLaunchClaim, type RuntimeHostReconciliationProvider, type RuntimeHostSupervisorProvider, } from './managed-deployment.js'; diff --git a/packages/runtime-host/src/operator/managed-deployment.ts b/packages/runtime-host/src/operator/managed-deployment.ts index f22564da6c..12fa97f30b 100644 --- a/packages/runtime-host/src/operator/managed-deployment.ts +++ b/packages/runtime-host/src/operator/managed-deployment.ts @@ -20,14 +20,13 @@ import { randomUUID } from 'node:crypto'; import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; import { userInfo } from 'node:os'; -import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path'; +import { dirname, isAbsolute, join, parse, posix, relative, resolve, sep, win32 } from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import { type StateRootOwner, type StorageRootCapability, tryAcquireStateRootOwner, } from '@maka/storage/root-authority'; -import { withProcessLifetimeFileUpdateLock } from '@maka/storage/process-lifetime-file-update-lock'; import { syncDirectory, syncDirectoryChain } from '@maka/storage/stable-storage'; import { z } from 'zod'; import { isProductReleaseVersion, isSha512PackageIntegrity } from './update-package-evidence.js'; @@ -36,7 +35,6 @@ export const RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE = 'runtime-host-deploym const SCHEMA_VERSION = 1 as const; const MAX_DOCUMENT_BYTES = 64 * 1024; -const UPDATE_LOCK_TIMEOUT_MS = 60_000; const ROOT_ID_PATTERN = /^[a-f0-9]{64}$/u; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; @@ -82,16 +80,6 @@ const lifecycleSchema = z.discriminatedUnion('mode', [ .strict(), ]); -const launchLifecycleSchema = z.discriminatedUnion('mode', [ - z.object({ mode: z.literal('on_demand') }).strict(), - z - .object({ - mode: z.literal('supervised'), - provider: providerSchema, - }) - .strict(), -]); - const reconciliationSchema = z.discriminatedUnion('trigger', [ z.object({ trigger: z.literal('manual') }).strict(), z.object({ trigger: z.literal('activation') }).strict(), @@ -107,7 +95,6 @@ const managedLaunchClaimSchema = z .object({ deploymentId: deploymentIdSchema, configRevision: configRevisionSchema, - lifecycle: launchLifecycleSchema, }) .strict(); @@ -204,29 +191,28 @@ export type RuntimeHostManagedSupervisedDeploymentConfig = RuntimeHostManagedDep readonly availability: 'session' | 'environment' | 'machine'; }; }; -export type RuntimeHostManagedOnDemandLaunchClaim = RuntimeHostManagedLaunchClaim & { - readonly lifecycle: { readonly mode: 'on_demand' }; -}; -export type RuntimeHostManagedSupervisedLaunchClaim = RuntimeHostManagedLaunchClaim & { - readonly lifecycle: { - readonly mode: 'supervised'; - readonly provider: RuntimeHostSupervisorProvider; - }; -}; export interface RuntimeHostManagedDeploymentAuthorityOptions { /** Test-only or embedding override. Production uses the account-local durable default. */ readonly authorityRoot?: string; readonly homeDir?: string; readonly platform?: NodeJS.Platform; + /** Pre-existing test/embedding durability anchor for an authorityRoot override. */ + readonly durabilityBoundary?: string; + /** Test-only durability fault injection. */ + readonly beforeDirectorySync?: (path: string) => void | Promise; } +export const RUNTIME_HOST_MANAGED_LAUNCH_REJECTIONS = [ + 'managed_root_requires_operator', + 'deployment_record_missing', + 'deployment_claim_mismatch', + 'deployment_lifecycle_mismatch', + 'deployment_record_invalid', +] as const; + export type RuntimeHostManagedLaunchRejection = - | 'managed_root_requires_operator' - | 'deployment_record_missing' - | 'deployment_claim_mismatch' - | 'deployment_lifecycle_mismatch' - | 'deployment_record_invalid'; + (typeof RUNTIME_HOST_MANAGED_LAUNCH_REJECTIONS)[number]; export class RuntimeHostManagedDeploymentError extends Error { constructor( @@ -271,21 +257,6 @@ export function decodeRuntimeHostManagedLaunchClaim(value: unknown): RuntimeHost } } -export function isRuntimeHostManagedOnDemandLaunchClaim( - claim: RuntimeHostManagedLaunchClaim, -): claim is RuntimeHostManagedOnDemandLaunchClaim { - return claim.lifecycle.mode === 'on_demand'; -} - -export function runtimeHostManagedLaunchClaim( - config: RuntimeHostManagedOnDemandDeploymentConfig, -): RuntimeHostManagedOnDemandLaunchClaim; -export function runtimeHostManagedLaunchClaim( - config: RuntimeHostManagedSupervisedDeploymentConfig, -): RuntimeHostManagedSupervisedLaunchClaim; -export function runtimeHostManagedLaunchClaim( - config: RuntimeHostManagedDeploymentConfig, -): RuntimeHostManagedLaunchClaim; export function runtimeHostManagedLaunchClaim( config: RuntimeHostManagedDeploymentConfig, ): RuntimeHostManagedLaunchClaim { @@ -293,10 +264,6 @@ export function runtimeHostManagedLaunchClaim( return { deploymentId: canonical.deploymentId, configRevision: canonical.configRevision, - lifecycle: - canonical.lifecycle.mode === 'on_demand' - ? { mode: 'on_demand' } - : { mode: 'supervised', provider: canonical.lifecycle.provider }, }; } @@ -346,42 +313,12 @@ export async function readRuntimeHostManagedDeploymentConfig( capability: StorageRootCapability<'interactive'>, options: RuntimeHostManagedDeploymentAuthorityOptions = {}, ): Promise { - const value = await readBoundedJson( + return readDeploymentConfigForCapability( resolveRuntimeHostManagedDeploymentConfigPath(capability.rootId, options), + capability, ); - if (value === undefined) return undefined; - const config = decodeRuntimeHostManagedDeploymentConfig(value); - assertConfigTargetsCapability(config, capability); - return config; } -export function claimRuntimeHostManagedDeployment( - capability: StorageRootCapability<'interactive'>, - config: RuntimeHostManagedOnDemandDeploymentConfig, - options?: RuntimeHostManagedDeploymentAuthorityOptions, -): Promise<{ - readonly kind: 'applied' | 'unchanged'; - readonly config: RuntimeHostManagedOnDemandDeploymentConfig; - readonly claim: RuntimeHostManagedOnDemandLaunchClaim; -}>; -export function claimRuntimeHostManagedDeployment( - capability: StorageRootCapability<'interactive'>, - config: RuntimeHostManagedSupervisedDeploymentConfig, - options?: RuntimeHostManagedDeploymentAuthorityOptions, -): Promise<{ - readonly kind: 'applied' | 'unchanged'; - readonly config: RuntimeHostManagedSupervisedDeploymentConfig; - readonly claim: RuntimeHostManagedSupervisedLaunchClaim; -}>; -export function claimRuntimeHostManagedDeployment( - capability: StorageRootCapability<'interactive'>, - config: RuntimeHostManagedDeploymentConfig, - options?: RuntimeHostManagedDeploymentAuthorityOptions, -): Promise<{ - readonly kind: 'applied' | 'unchanged'; - readonly config: RuntimeHostManagedDeploymentConfig; - readonly claim: RuntimeHostManagedLaunchClaim; -}>; export async function claimRuntimeHostManagedDeployment( capability: StorageRootCapability<'interactive'>, config: RuntimeHostManagedDeploymentConfig, @@ -395,46 +332,36 @@ export async function claimRuntimeHostManagedDeployment( assertConfigTargetsCapability(canonical, capability); const authorityRoot = resolveRuntimeHostManagedDeploymentAuthorityRoot(options); const path = resolveRuntimeHostManagedDeploymentConfigPath(capability.rootId, options); - await prepareAuthorityDirectory(dirname(path), authorityRoot); - return withProcessLifetimeFileUpdateLock( - path, - async () => { - const currentValue = await readBoundedJson(path); - if (currentValue !== undefined) { - const current = decodeRuntimeHostManagedDeploymentConfig(currentValue); - assertConfigTargetsCapability(current, capability); - if (isDeepStrictEqual(current, canonical)) { - return { - kind: 'unchanged', - config: current, - claim: runtimeHostManagedLaunchClaim(current), - }; - } - throw new RuntimeHostManagedDeploymentError( - 'lifecycle_owner_exists', - 'The State Root already has a managed deployment', - ); - } - const owner = await tryAcquireStateRootOwner(capability); - if (!owner) { - throw new RuntimeHostManagedDeploymentError( - 'state_root_owned', - 'The State Root must be retired before it can become managed', - ); - } - try { - await writePrivateJson(path, canonical); - } finally { - await owner.close(); - } - return { - kind: 'applied', - config: canonical, - claim: runtimeHostManagedLaunchClaim(canonical), - }; - }, - UPDATE_LOCK_TIMEOUT_MS, + await prepareAuthorityDirectory( + dirname(path), + resolveAuthorityDurabilityBoundary(authorityRoot, options), + options, ); + + const existing = await readDeploymentConfigForCapability(path, capability); + if (existing !== undefined) return existingDeploymentClaim(existing, canonical); + + const owner = await tryAcquireStateRootOwner(capability); + if (!owner) { + const raced = await readDeploymentConfigForCapability(path, capability); + if (raced !== undefined) return existingDeploymentClaim(raced, canonical); + throw new RuntimeHostManagedDeploymentError( + 'state_root_owned', + 'The State Root must be retired before it can become managed', + ); + } + try { + const current = await readDeploymentConfigForCapability(path, capability); + if (current !== undefined) return existingDeploymentClaim(current, canonical); + await writePrivateJson(path, canonical); + return { + kind: 'applied', + config: canonical, + claim: runtimeHostManagedLaunchClaim(canonical), + }; + } finally { + await owner.close(); + } } export async function tryAcquireRuntimeHostLaunchOwner( @@ -445,41 +372,42 @@ export async function tryAcquireRuntimeHostLaunchOwner( ): Promise | undefined> { const canonicalClaim = claim === undefined ? undefined : decodeRuntimeHostManagedLaunchClaim(claim); - const authorityRoot = resolveRuntimeHostManagedDeploymentAuthorityRoot(options); const path = resolveRuntimeHostManagedDeploymentConfigPath(capability.rootId, options); - await prepareAuthorityDirectory(dirname(path), authorityRoot); - return withProcessLifetimeFileUpdateLock( - path, - async () => { - const configValue = await readBoundedJson(path); - let config: RuntimeHostManagedDeploymentConfig | undefined; - if (configValue !== undefined) { - try { - config = decodeRuntimeHostManagedDeploymentConfig(configValue); - assertConfigTargetsCapability(config, capability); - } catch (error) { - throw new RuntimeHostManagedDeploymentError( - 'deployment_record_invalid', - 'The Runtime Host managed deployment record is invalid', - { cause: error }, - ); - } + const owner = await tryAcquireStateRootOwner(capability); + if (!owner) return undefined; + try { + let config: RuntimeHostManagedDeploymentConfig | undefined; + try { + config = await readDeploymentConfigForCapability(path, capability); + } catch (error) { + if ( + !(error instanceof RuntimeHostManagedDeploymentError) || + error.code !== 'invalid_config' + ) { + throw error; } - const rejection = runtimeHostManagedLaunchRejection( - config, - canonicalClaim, - expectedLifecycleMode, + throw new RuntimeHostManagedDeploymentError( + 'deployment_record_invalid', + 'The Runtime Host managed deployment record is invalid', + { cause: error }, ); - if (rejection !== undefined) { - throw new RuntimeHostManagedDeploymentError( - rejection, - managedLaunchRejectionMessage(rejection), - ); - } - return tryAcquireStateRootOwner(capability); - }, - UPDATE_LOCK_TIMEOUT_MS, - ); + } + const rejection = runtimeHostManagedLaunchRejection( + config, + canonicalClaim, + expectedLifecycleMode, + ); + if (rejection !== undefined) { + throw new RuntimeHostManagedDeploymentError( + rejection, + managedLaunchRejectionMessage(rejection), + ); + } + return owner; + } catch (error) { + await owner.close(); + throw error; + } } export function runtimeHostManagedLaunchRejection( @@ -501,20 +429,43 @@ function sameManagedLaunch( expected: RuntimeHostManagedLaunchClaim, claim: RuntimeHostManagedLaunchClaim, ): boolean { - if ( - expected.deploymentId !== claim.deploymentId || - expected.configRevision !== claim.configRevision || - expected.lifecycle.mode !== claim.lifecycle.mode - ) { - return false; - } return ( - expected.lifecycle.mode !== 'supervised' || - (claim.lifecycle.mode === 'supervised' && - expected.lifecycle.provider === claim.lifecycle.provider) + expected.deploymentId === claim.deploymentId && expected.configRevision === claim.configRevision ); } +function existingDeploymentClaim( + existing: RuntimeHostManagedDeploymentConfig, + requested: RuntimeHostManagedDeploymentConfig, +): { + readonly kind: 'unchanged'; + readonly config: RuntimeHostManagedDeploymentConfig; + readonly claim: RuntimeHostManagedLaunchClaim; +} { + if (!isDeepStrictEqual(existing, requested)) { + throw new RuntimeHostManagedDeploymentError( + 'lifecycle_owner_exists', + 'The State Root already has a managed deployment', + ); + } + return { + kind: 'unchanged', + config: existing, + claim: runtimeHostManagedLaunchClaim(existing), + }; +} + +async function readDeploymentConfigForCapability( + path: string, + capability: StorageRootCapability<'interactive'>, +): Promise { + const value = await readBoundedJson(path); + if (value === undefined) return undefined; + const config = decodeRuntimeHostManagedDeploymentConfig(value); + assertConfigTargetsCapability(config, capability); + return config; +} + function managedLaunchRejectionMessage(rejection: RuntimeHostManagedLaunchRejection): string { switch (rejection) { case 'managed_root_requires_operator': @@ -559,17 +510,21 @@ async function readBoundedJson(path: string): Promise { 'The Runtime Host managed deployment record must be a bounded regular file', ); } + let contents: string; + try { + contents = await readFile(path, 'utf8'); + } catch (error) { + throw deploymentIo('Unable to read the Runtime Host managed deployment record', error); + } + if (Buffer.byteLength(contents, 'utf8') > MAX_DOCUMENT_BYTES) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host managed deployment record exceeds its size limit', + ); + } try { - const contents = await readFile(path, 'utf8'); - if (Buffer.byteLength(contents, 'utf8') > MAX_DOCUMENT_BYTES) { - throw new RuntimeHostManagedDeploymentError( - 'invalid_config', - 'The Runtime Host managed deployment record exceeds its size limit', - ); - } return JSON.parse(contents) as unknown; } catch (error) { - if (error instanceof RuntimeHostManagedDeploymentError) throw error; throw new RuntimeHostManagedDeploymentError( 'invalid_config', 'The Runtime Host managed deployment record is not valid JSON', @@ -615,8 +570,16 @@ async function writePrivateJson(path: string, value: unknown): Promise { } } -async function prepareAuthorityDirectory(path: string, authorityRoot: string): Promise { +async function prepareAuthorityDirectory( + path: string, + durabilityBoundary: string, + options: RuntimeHostManagedDeploymentAuthorityOptions, +): Promise { try { + const boundaryMetadata = await lstat(durabilityBoundary); + if (!boundaryMetadata.isDirectory() || boundaryMetadata.isSymbolicLink()) { + throw new Error('Managed deployment durability boundary is not a directory'); + } await mkdir(path, { recursive: true, mode: 0o700 }); const metadata = await lstat(path); if (!metadata.isDirectory() || metadata.isSymbolicLink()) { @@ -628,12 +591,42 @@ async function prepareAuthorityDirectory(path: string, authorityRoot: string): P } await chmod(path, 0o700); } - await syncDirectoryChain(path, dirname(authorityRoot)); + await syncDirectoryChain(path, durabilityBoundary, options.beforeDirectorySync); } catch (error) { throw deploymentIo('Unable to prepare the Runtime Host managed deployment authority', error); } } +function resolveAuthorityDurabilityBoundary( + authorityRoot: string, + options: RuntimeHostManagedDeploymentAuthorityOptions, +): string { + if (options.durabilityBoundary !== undefined && !isAbsolute(options.durabilityBoundary)) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host managed deployment durability boundary must be absolute', + ); + } + const boundary = resolve( + options.durabilityBoundary ?? + (options.authorityRoot === undefined + ? (options.homeDir ?? userInfo().homedir) + : parse(authorityRoot).root), + ); + const pathFromBoundary = relative(boundary, authorityRoot); + if ( + pathFromBoundary === '..' || + pathFromBoundary.startsWith(`..${sep}`) || + isAbsolute(pathFromBoundary) + ) { + throw new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host managed deployment durability boundary must contain the authority root', + ); + } + return boundary; +} + function requireRootId(rootId: string): void { if (!ROOT_ID_PATTERN.test(rootId)) { throw new RuntimeHostManagedDeploymentError( diff --git a/packages/runtime-host/src/server/candidate.ts b/packages/runtime-host/src/server/candidate.ts index 188b7c2afd..49fe491fa5 100644 --- a/packages/runtime-host/src/server/candidate.ts +++ b/packages/runtime-host/src/server/candidate.ts @@ -21,7 +21,7 @@ import { resolveExistingStorageRoot } from '@maka/storage/root-authority'; import { tryAcquireRuntimeHostLaunchOwner, type RuntimeHostManagedDeploymentAuthorityOptions, - type RuntimeHostManagedOnDemandLaunchClaim, + type RuntimeHostManagedLaunchClaim, } from '../operator/managed-deployment.js'; import type { RuntimeHostCompositionSource } from './host-composition.js'; import { RuntimeHostKernel } from './host-kernel.js'; @@ -33,7 +33,7 @@ export interface InteractiveRuntimeHostCandidateOptions { idleGraceMs?: number; handshakeTimeoutMs?: number; generation?: string; - managedLaunchClaim?: RuntimeHostManagedOnDemandLaunchClaim; + managedLaunchClaim?: RuntimeHostManagedLaunchClaim; } export interface InteractiveRuntimeHostCandidateDependencies { diff --git a/packages/runtime-host/src/server/execution-service.ts b/packages/runtime-host/src/server/execution-service.ts index 1669bf2c0b..16e6ef7b2b 100644 --- a/packages/runtime-host/src/server/execution-service.ts +++ b/packages/runtime-host/src/server/execution-service.ts @@ -25,7 +25,7 @@ import { import { tryAcquireRuntimeHostLaunchOwner, type RuntimeHostManagedDeploymentAuthorityOptions, - type RuntimeHostManagedSupervisedLaunchClaim, + type RuntimeHostManagedLaunchClaim, } from '../operator/managed-deployment.js'; import { RuntimeHostKernel } from './host-kernel.js'; import { openRuntimeHostAccessAuthority } from './access-authority.js'; @@ -39,7 +39,7 @@ export interface ExecutionRuntimeHostServiceOptions { readonly projectDirectoryRoots?: readonly PublishedProjectDirectoryRoot[]; readonly handshakeTimeoutMs?: number; readonly shutdownGraceMs?: number; - readonly managedLaunchClaim?: RuntimeHostManagedSupervisedLaunchClaim; + readonly managedLaunchClaim?: RuntimeHostManagedLaunchClaim; readonly websocket?: Omit< StartRuntimeHostWebSocketListenerOptions, 'accessAuthority' | 'accept' | 'isReady' diff --git a/packages/storage/src/__tests__/fixtures/control-directory-hygiene.ts b/packages/storage/src/__tests__/fixtures/control-directory-hygiene.ts index 37273feeeb..b4b969adbc 100644 --- a/packages/storage/src/__tests__/fixtures/control-directory-hygiene.ts +++ b/packages/storage/src/__tests__/fixtures/control-directory-hygiene.ts @@ -19,7 +19,11 @@ import { readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import { resolveRootControlNamespace, STORAGE_ROOT_MARKER_FILE } from '../../root-authority.js'; +import { + resolveRootControlNamespace, + resolveRootOwnershipNamespace, + STORAGE_ROOT_MARKER_FILE, +} from '../../root-authority.js'; // A storage root's control directory lives under the real OS account home, not // inside the temporary root a test creates, so removing the temporary directory @@ -32,7 +36,10 @@ import { resolveRootControlNamespace, STORAGE_ROOT_MARKER_FILE } from '../../roo /** Removes the control directory for a rootId. Safe to call when none exists. */ export async function removeControlDirectory(rootId: string): Promise { if (rootId.length === 0) return; - await rm(join(resolveRootControlNamespace(), rootId), { recursive: true, force: true }); + await Promise.all([ + rm(join(resolveRootControlNamespace(), rootId), { recursive: true, force: true }), + rm(join(resolveRootOwnershipNamespace(), `${rootId}.lock`), { force: true }), + ]); } /** diff --git a/packages/storage/src/__tests__/root-authority.test.ts b/packages/storage/src/__tests__/root-authority.test.ts index 339a39da96..06cb4b4084 100644 --- a/packages/storage/src/__tests__/root-authority.test.ts +++ b/packages/storage/src/__tests__/root-authority.test.ts @@ -49,6 +49,7 @@ import { resolveExistingStorageRoot, resolveExistingStorageRootControlDirectory, resolveRootControlNamespace, + resolveRootOwnershipNamespace, resolveStorageRoot, runWithStorageRootLease, STORAGE_ROOT_MARKER_FILE, @@ -759,6 +760,24 @@ describe('storage root authority', () => { }); }); + test('cache deletion cannot create a second State Root owner', { + skip: process.platform === 'win32', + }, async () => { + await withRoots(async ({ root }) => { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + await rm(owner.controlDirectory, { recursive: true, force: true }); + + assert.equal(await tryAcquireInteractiveRootOwner(capability), undefined); + await owner.close(); + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + await successor.close(); + }); + }); + test('validates an existing control directory without repairing its permissions', { skip: process.platform === 'win32', }, async () => { @@ -1110,9 +1129,10 @@ async function removeControlDirectoriesForRootsUnder(base: string): Promise(); await collectRootIds(base, rootIds); await Promise.all( - [...rootIds].map((rootId) => + [...rootIds].flatMap((rootId) => [ rm(join(resolveRootControlNamespace(), rootId), { recursive: true, force: true }), - ), + rm(join(resolveRootOwnershipNamespace(), `${rootId}.lock`), { force: true }), + ]), ); } diff --git a/packages/storage/src/root-authority.ts b/packages/storage/src/root-authority.ts index fc7bd13820..0b62119449 100644 --- a/packages/storage/src/root-authority.ts +++ b/packages/storage/src/root-authority.ts @@ -26,6 +26,7 @@ import { tryLock, unlock, waitForLock } from 'fs-native-extensions'; import { withArtifactWriterBootstrapLock } from './artifact-writer-bootstrap-lock.js'; import { publishMarkerFile, readBoundedMarkerFile } from './marker-file.js'; +import { syncDirectoryChain } from './stable-storage.js'; export const STORAGE_ROOT_MARKER_FILE = '.maka-storage-root.json'; export const STORAGE_ROOT_MARKER_SCHEMA_VERSION = 1 as const; @@ -504,6 +505,35 @@ export function resolveRootControlNamespace(): string { } } +/** + * Resolves the durable namespace that owns State Root process election. + * + * Endpoint registrations and diagnostics remain in the disposable control + * namespace. The owner lock must not: deleting a cache directory while a Host + * is running must never make the same State Root acquirable again. + */ +export function resolveRootOwnershipNamespace(): string { + try { + const accountHome = userInfo().homedir; + if (!isAbsolute(accountHome)) { + throw new Error('OS account home must be an absolute path'); + } + if (process.platform === 'darwin') { + return join(accountHome, 'Library', 'Application Support', 'Maka', 'state-root-owners'); + } + if (process.platform === 'win32') { + return join(accountHome, 'AppData', 'Local', 'Maka', 'state-root-owners'); + } + return join(accountHome, '.local', 'share', 'Maka', 'state-root-owners'); + } catch (error) { + throw normalizeAuthorityFailure( + error, + 'control_io_failed', + 'Unable to resolve the State Root ownership namespace', + ); + } +} + export async function tryAcquireInteractiveRootOwner( capability: StorageRootCapability<'interactive'>, ): Promise { @@ -717,38 +747,33 @@ async function acquireStateRootLock( access: StorageRootAccess, ): Promise | StateRootReader | undefined> { const capabilityRecord = requireCapability(capability, capability.kind); - const { controlDirectory } = await prepareStorageRootControlDirectory(capability); - const lockPath = join(controlDirectory, 'owner.lock'); - const existingLock = await lstatPathIfPresent(lockPath); - if (existingLock && !existingLock.isFile()) { - throw invalidLockArtifact(lockPath); - } - const handle = await open(lockPath, 'a+', 0o600); - try { - await assertStableLockArtifact(handle, lockPath); - await handle.chmod(0o600); - } catch (error) { - await handle.close(); - throw error; - } - - let granted = false; - try { - granted = tryLock(handle.fd, { shared: access === 'read' }); - } catch (error) { - await handle.close(); - throw error; - } - if (!granted) { - await handle.close(); - return undefined; - } + const ownershipRoot = resolve(resolveRootOwnershipNamespace()); + await ensureDurablePrivateDirectory(ownershipRoot); + const lockPath = join(ownershipRoot, `${capabilityRecord.rootId}.lock`); + const durableHandle = await tryAcquireStableRootLock(lockPath, access); + if (!durableHandle) return undefined; + + let compatibilityHandle: FileHandle | undefined; + let controlDirectory: string; try { - await assertStableLockArtifact(handle, lockPath); + ({ controlDirectory } = await prepareStorageRootControlDirectory(capability)); + compatibilityHandle = await tryAcquireStableRootLock( + join(controlDirectory, 'owner.lock'), + access, + ); + if (!compatibilityHandle) { + releaseLock(durableHandle); + await durableHandle.close(); + return undefined; + } await assertRootIdentity(capabilityRecord); } catch (error) { - releaseLock(handle); - await handle.close(); + if (compatibilityHandle) { + releaseLock(compatibilityHandle); + await compatibilityHandle.close().catch(() => undefined); + } + releaseLock(durableHandle); + await durableHandle.close().catch(() => undefined); throw error; } @@ -781,8 +806,14 @@ async function acquireStateRootLock( 'Unable to close the storage root lock', async () => { await waitForOperations(); - releaseLock(handle); - await handle.close(); + const errors: unknown[] = []; + releaseLock(compatibilityHandle); + await compatibilityHandle.close().catch((error: unknown) => errors.push(error)); + releaseLock(durableHandle); + await durableHandle.close().catch((error: unknown) => errors.push(error)); + if (errors.length > 0) { + throw new AggregateError(errors, 'Unable to close every State Root owner lock'); + } }, ); return closePromise; @@ -799,6 +830,28 @@ async function acquireStateRootLock( ); } +async function tryAcquireStableRootLock( + lockPath: string, + access: StorageRootAccess, +): Promise { + const existingLock = await lstatPathIfPresent(lockPath); + if (existingLock && !existingLock.isFile()) throw invalidLockArtifact(lockPath); + const handle = await open(lockPath, 'a+', 0o600); + try { + await assertStableLockArtifact(handle, lockPath); + await handle.chmod(0o600); + if (!tryLock(handle.fd, { shared: access === 'read' })) { + await handle.close(); + return undefined; + } + await assertStableLockArtifact(handle, lockPath); + return handle; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +} + function createStateRootLock( capability: StorageRootCapability, capabilityRecord: CapabilityRecord, @@ -923,6 +976,17 @@ async function preparePrivateControlRoot(): Promise { return controlRoot; } +async function ensureDurablePrivateDirectory(path: string): Promise { + let existingAncestor = path; + while ((await lstatPathIfPresent(existingAncestor)) === undefined) { + const parent = parse(existingAncestor).dir; + if (parent === existingAncestor) break; + existingAncestor = parent; + } + await ensurePrivateDirectory(path); + await syncDirectoryChain(path, existingAncestor); +} + async function prepareArtifactWriterBootstrapLockPathForIdentity( controlRoot: string, identity: RootIdentity, diff --git a/packages/storage/src/stable-storage.ts b/packages/storage/src/stable-storage.ts index 90aafe4ab2..21feb37a53 100644 --- a/packages/storage/src/stable-storage.ts +++ b/packages/storage/src/stable-storage.ts @@ -32,7 +32,11 @@ export async function syncFile(path: string): Promise { } } -export async function syncDirectoryChain(path: string, root: string): Promise { +export async function syncDirectoryChain( + path: string, + root: string, + beforeSync?: (path: string) => void | Promise, +): Promise { const boundary = resolve(root); let current = resolve(path); const pathFromBoundary = relative(boundary, current); @@ -44,6 +48,7 @@ export async function syncDirectoryChain(path: string, root: string): Promise Date: Thu, 27 Aug 2026 16:56:22 +0800 Subject: [PATCH 04/11] test(runtime-host): register Windows exclusions Generated-by: OpenAI Codex --- docs/windows-test-inventory.md | 11 +++++++---- .../src/__tests__/managed-deployment.test.ts | 7 +++++-- packages/storage/src/__tests__/root-authority.test.ts | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index f95c7f0eab..1858d0953a 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -15,11 +15,11 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| -| windows-backend-gap | 23 | -| portable-candidate | 8 | -| platform-contract | 35 | +| windows-backend-gap | 24 | +| portable-candidate | 9 | +| platform-contract | 36 | -Total Windows-excluded declarations: **66** +Total Windows-excluded declarations: **69** ## Inventory @@ -45,6 +45,8 @@ Total Windows-excluded declarations: **66** | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` a non-reading Client overload is isolated to its connection | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` reports one shutdown failure through close and closed while releasing ownership | `process.platform === 'win32'` | | platform-contract | `packages/runtime-host/src/__tests__/host-kernel.test.ts` publishes private POSIX endpoint and registration permissions | `process.platform === 'win32'` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/managed-deployment.test.ts` managed ownership survives deletion of the disposable control cache | `process.platform === 'win32' ? 'Windows does not unlink an open native lock file' : false` | +| platform-contract | `packages/runtime-host/src/__tests__/managed-deployment.test.ts` keeps transient deployment record I/O retryable at the Candidate boundary | `process.platform === 'win32' ? 'POSIX file permissions are required to make the record unreadable' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/memory-two-client-uds.test.ts` two UDS clients share one recoverable Memory authority across Host death | `process.platform === 'win32' ? 'POSIX process death gate' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts` two UDS clients converge on one Host-owned Project Catalog | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts` invalidates when a real published mutation loses its commit reply | `process.platform === 'win32'` | @@ -81,6 +83,7 @@ Total Windows-excluded declarations: **66** | platform-contract | `packages/storage/src/__tests__/root-authority.test.ts` preserves unexpected marker I/O failures at the public authority boundary | `process.platform === 'win32' ? 'POSIX permissions are required to make the marker unreadable' : typeof process.getuid === 'function' && process.getuid() === 0` | | platform-contract | `packages/storage/src/__tests__/root-authority.test.ts` rejects FIFO marker paths without blocking root resolution | `process.platform === 'win32'` | | platform-contract | `packages/storage/src/__tests__/root-authority.test.ts` rejects a lock path that aliases another filesystem object | `process.platform === 'win32' ? 'Windows file-symlink permissions are not guaranteed in CI' : false` | +| portable-candidate | `packages/storage/src/__tests__/root-authority.test.ts` cache deletion cannot create a second State Root owner | `process.platform === 'win32' ? 'Windows does not unlink an open native lock file' : false` | | platform-contract | `packages/storage/src/__tests__/root-authority.test.ts` validates an existing control directory without repairing its permissions | `process.platform === 'win32'` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` reports unknown outcome when credential persistence fails after clearing verified state | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` validates proxy policy mutations before clearing and reports failed follow-up commits as unknown | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | diff --git a/packages/runtime-host/src/__tests__/managed-deployment.test.ts b/packages/runtime-host/src/__tests__/managed-deployment.test.ts index 8f8c582b3c..e8f49b7a99 100644 --- a/packages/runtime-host/src/__tests__/managed-deployment.test.ts +++ b/packages/runtime-host/src/__tests__/managed-deployment.test.ts @@ -317,7 +317,7 @@ test('concurrent managed activations elect exactly one State Root owner', async }); test('managed ownership survives deletion of the disposable control cache', { - skip: process.platform === 'win32', + skip: process.platform === 'win32' ? 'Windows does not unlink an open native lock file' : false, }, async (t) => { const input = await fixture(t); const { claim } = await claimRuntimeHostManagedDeployment( @@ -402,7 +402,10 @@ test('normalizes unreadable deployment records at the launch authority boundary' }); test('keeps transient deployment record I/O retryable at the Candidate boundary', { - skip: process.platform === 'win32', + skip: + process.platform === 'win32' + ? 'POSIX file permissions are required to make the record unreadable' + : false, }, async (t) => { const input = await fixture(t); const { claim } = await claimRuntimeHostManagedDeployment( diff --git a/packages/storage/src/__tests__/root-authority.test.ts b/packages/storage/src/__tests__/root-authority.test.ts index 06cb4b4084..450b5e8135 100644 --- a/packages/storage/src/__tests__/root-authority.test.ts +++ b/packages/storage/src/__tests__/root-authority.test.ts @@ -761,7 +761,7 @@ describe('storage root authority', () => { }); test('cache deletion cannot create a second State Root owner', { - skip: process.platform === 'win32', + skip: process.platform === 'win32' ? 'Windows does not unlink an open native lock file' : false, }, async () => { await withRoots(async ({ root }) => { const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); From 368f035948b46a6e477d0b835f2aa23f825aa621 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Thu, 27 Aug 2026 17:37:03 +0800 Subject: [PATCH 05/11] refactor(runtime-host): narrow managed deployment API Keep launch authorization mechanics internal and remove unused lifecycle aliases. Generated-by: OpenAI Codex --- .../runtime-host/src/__tests__/host-kernel.test.ts | 4 ++-- packages/runtime-host/src/operator/index.ts | 6 ------ .../runtime-host/src/operator/managed-deployment.ts | 10 ---------- 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index cbb1f6d98e..09c49733d3 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -66,7 +66,7 @@ import { import { claimRuntimeHostManagedDeployment, resolveRuntimeHostManagedDeploymentConfigPath, - type RuntimeHostManagedOnDemandDeploymentConfig, + type RuntimeHostManagedDeploymentConfig, } from '../operator/managed-deployment.js'; import { removePosixEndpointDirectories } from './fixtures/endpoint-hygiene.js'; import { @@ -3218,7 +3218,7 @@ describe('non-serving Runtime Host kernel', () => { function managedDeploymentConfig( capability: StorageRootCapability<'interactive'>, -): RuntimeHostManagedOnDemandDeploymentConfig { +): RuntimeHostManagedDeploymentConfig { return { schemaVersion: 1, deploymentId: '00000000-0000-4000-8000-000000000001', diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 19dd9eb0d9..186b1a2047 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -105,23 +105,17 @@ export { } from './local-process-deployment-handoff.js'; export { RUNTIME_HOST_MANAGED_DEPLOYMENT_CONFIG_FILE, - RUNTIME_HOST_MANAGED_LAUNCH_REJECTIONS, RuntimeHostManagedDeploymentError, claimRuntimeHostManagedDeployment, decodeRuntimeHostManagedDeploymentConfig, - decodeRuntimeHostManagedLaunchClaim, readRuntimeHostManagedDeploymentConfig, resolveRuntimeHostManagedDeploymentAuthorityRoot, resolveRuntimeHostManagedDeploymentConfigPath, runtimeHostManagedLaunchClaim, - runtimeHostManagedLaunchRejection, - tryAcquireRuntimeHostLaunchOwner, type RuntimeHostManagedDeploymentAuthorityOptions, type RuntimeHostManagedDeploymentConfig, type RuntimeHostManagedLaunchClaim, type RuntimeHostManagedLaunchRejection, - type RuntimeHostManagedOnDemandDeploymentConfig, - type RuntimeHostManagedSupervisedDeploymentConfig, type RuntimeHostReconciliationProvider, type RuntimeHostSupervisorProvider, } from './managed-deployment.js'; diff --git a/packages/runtime-host/src/operator/managed-deployment.ts b/packages/runtime-host/src/operator/managed-deployment.ts index 12fa97f30b..2adfbf0223 100644 --- a/packages/runtime-host/src/operator/managed-deployment.ts +++ b/packages/runtime-host/src/operator/managed-deployment.ts @@ -181,16 +181,6 @@ export type RuntimeHostSupervisorProvider = z.infer; export type RuntimeHostReconciliationProvider = z.infer; export type RuntimeHostManagedDeploymentConfig = z.infer; export type RuntimeHostManagedLaunchClaim = z.infer; -export type RuntimeHostManagedOnDemandDeploymentConfig = RuntimeHostManagedDeploymentConfig & { - readonly lifecycle: { readonly mode: 'on_demand'; readonly availability: 'activation' }; -}; -export type RuntimeHostManagedSupervisedDeploymentConfig = RuntimeHostManagedDeploymentConfig & { - readonly lifecycle: { - readonly mode: 'supervised'; - readonly provider: RuntimeHostSupervisorProvider; - readonly availability: 'session' | 'environment' | 'machine'; - }; -}; export interface RuntimeHostManagedDeploymentAuthorityOptions { /** Test-only or embedding override. Production uses the account-local durable default. */ From 94ca31a1227dfd4d3b9c853c62f27b569f7e8bc5 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Thu, 27 Aug 2026 18:32:26 +0800 Subject: [PATCH 06/11] fix(runtime-host): bind managed launch artifacts Read authority files through one stable bounded descriptor path and derive managed entrypoints from the exact package identity before admitting State Root ownership. Generated-by: OpenAI Codex --- .../src/runtime-host-package-deployment.ts | 34 ++-- .../candidate-startup-failure.test.ts | 1 + .../src/__tests__/host-kernel.test.ts | 46 ++++- .../src/__tests__/managed-deployment.test.ts | 180 ++++++++++++++++-- .../src/__tests__/startup-error.test.ts | 7 + packages/runtime-host/src/candidate-entry.ts | 10 +- .../src/candidate-startup-failure.ts | 1 + .../runtime-host/src/client/startup-error.ts | 5 + .../src/execution-candidate-main.ts | 2 +- packages/runtime-host/src/operator/index.ts | 2 + .../src/operator/managed-deployment.ts | 116 +++++++++-- .../src/operator/update-package-evidence.ts | 32 ++++ packages/runtime-host/src/server/candidate.ts | 11 +- .../src/server/execution-candidate.ts | 7 +- .../src/server/execution-service.ts | 11 +- .../test-only/execution-candidate-e2e-main.ts | 2 +- .../storage/src/__tests__/marker-file.test.ts | 2 +- .../src/__tests__/stable-storage.test.ts | 105 ++++++++++ packages/storage/src/marker-file.ts | 35 +--- packages/storage/src/stable-storage.ts | 124 +++++++++++- 20 files changed, 634 insertions(+), 99 deletions(-) create mode 100644 packages/storage/src/__tests__/stable-storage.test.ts diff --git a/packages/cli/src/runtime-host-package-deployment.ts b/packages/cli/src/runtime-host-package-deployment.ts index bc84884a33..2e7e5e997f 100644 --- a/packages/cli/src/runtime-host-package-deployment.ts +++ b/packages/cli/src/runtime-host-package-deployment.ts @@ -17,10 +17,10 @@ * under the License. */ -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; import { cp, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { isSha512PackageIntegrity } from '@maka/runtime-host/operator'; +import { resolveRuntimeHostNpmDeploymentLayout } from '@maka/runtime-host/operator'; const PACKAGE_NAME = 'maka-agent'; @@ -50,8 +50,9 @@ export function resolveRuntimeHostPackageCliPath( packageIntegrity?: string, ): string { assertVersion(version); - const packageDirectory = packageIntegrity ? registryPackageDirectory(packageIntegrity) : version; - return join(resolve(deploymentRoot), 'versions', packageDirectory, 'dist', 'cli.js'); + return packageIntegrity + ? registryPackageLayout(deploymentRoot, packageIntegrity).cliPath + : join(resolve(deploymentRoot), 'versions', version, 'dist', 'cli.js'); } export async function prepareRuntimeHostPackageDeployment(input: { @@ -65,15 +66,14 @@ export async function prepareRuntimeHostPackageDeployment(input: { await mkdir(join(input.deploymentRoot, 'versions'), { recursive: true, mode: 0o700 }); const deploymentRoot = await realpath(resolve(input.deploymentRoot)); const versionsRoot = join(deploymentRoot, 'versions'); - const packageDirectory = input.packageIntegrity - ? registryPackageDirectory(input.packageIntegrity) - : input.version; - const packageRoot = join(versionsRoot, packageDirectory); - const cliPath = resolveRuntimeHostPackageCliPath( - deploymentRoot, - input.version, - input.packageIntegrity, - ); + const layout = input.packageIntegrity + ? registryPackageLayout(deploymentRoot, input.packageIntegrity) + : { + packageRoot: join(versionsRoot, input.version), + cliPath: join(versionsRoot, input.version, 'dist', 'cli.js'), + }; + const { packageRoot, cliPath } = layout; + const packageDirectory = basename(packageRoot); if (await pathExists(packageRoot)) { await validatePackage(packageRoot, input.version); return deployment(input.version, deploymentRoot, packageRoot, cliPath, false); @@ -254,14 +254,16 @@ async function removePackageAtomically(versionsRoot: string, packageName: string } } -function registryPackageDirectory(integrity: string): string { - if (!isSha512PackageIntegrity(integrity)) { +function registryPackageLayout(deploymentRoot: string, integrity: string) { + try { + return resolveRuntimeHostNpmDeploymentLayout(deploymentRoot, integrity); + } catch (error) { throw new RuntimeHostPackageDeploymentError( 'invalid_package', 'The Runtime Host package integrity is invalid', + { cause: error }, ); } - return `registry-${createHash('sha256').update(integrity).digest('hex')}`; } function assertVersion(version: string): void { diff --git a/packages/runtime-host/src/__tests__/candidate-startup-failure.test.ts b/packages/runtime-host/src/__tests__/candidate-startup-failure.test.ts index 679f85003c..dba1e716f4 100644 --- a/packages/runtime-host/src/__tests__/candidate-startup-failure.test.ts +++ b/packages/runtime-host/src/__tests__/candidate-startup-failure.test.ts @@ -114,6 +114,7 @@ test('preserves managed authority rejections as permanent bounded diagnostics', 'deployment_record_missing', 'deployment_claim_mismatch', 'deployment_lifecycle_mismatch', + 'deployment_launch_mismatch', 'deployment_record_invalid', ] as const; diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 09c49733d3..e9ba616fa1 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -68,6 +68,7 @@ import { resolveRuntimeHostManagedDeploymentConfigPath, type RuntimeHostManagedDeploymentConfig, } from '../operator/managed-deployment.js'; +import { resolveRuntimeHostNpmDeploymentLayout } from '../operator/update-package-evidence.js'; import { removePosixEndpointDirectories } from './fixtures/endpoint-hygiene.js'; import { decodeHostFrame, @@ -296,6 +297,50 @@ describe('non-serving Runtime Host kernel', () => { }); }); + test('a mismatched exact-package launch crosses the Candidate boundary as exit 85', async () => { + await withHostPaths(async (paths) => { + const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); + const managedDeploymentAuthority = { + authorityRoot: join(paths.base, 'managed-authority'), + durabilityBoundary: paths.base, + }; + const config = managedDeploymentConfig(capability); + const { claim } = await claimRuntimeHostManagedDeployment( + capability, + config, + managedDeploymentAuthority, + ); + + await assert.rejects( + startInteractiveRuntimeHostCandidate( + { + rootPath: capability.canonicalPath, + expectedRootId: capability.rootId, + managedLaunchClaim: claim, + }, + KERNEL_COMPOSITION, + { + managedDeploymentAuthority, + processLaunch: { + executablePath: config.launch.nodePath, + entrypointPath: + resolveRuntimeHostNpmDeploymentLayout( + config.deploymentRoot, + config.launch.package.integrity, + ).candidateEntrypoint + '.stale', + }, + }, + ), + (error: unknown) => { + const failure = classifyCandidateStartupFailure(error); + assert.deepEqual(failure, { reason: 'deployment_launch_mismatch' }); + assert.equal(candidateStartupFailureExitCode(failure), 85); + return true; + }, + ); + }); + }); + test('reports a recovery failure when the election produces no ready Host', async () => { await withHostPaths(async (paths) => { const result = await connectOrSpawnRuntimeHostWithDependencies( @@ -3229,7 +3274,6 @@ function managedDeploymentConfig( launch: { kind: 'exact_package', nodePath: '/usr/bin/node', - cliPath: '/opt/maka/runtime-host/versions/1.2.3/cli.js', package: { kind: 'npm_registry', version: '1.2.3', diff --git a/packages/runtime-host/src/__tests__/managed-deployment.test.ts b/packages/runtime-host/src/__tests__/managed-deployment.test.ts index e8f49b7a99..d0d5b787dd 100644 --- a/packages/runtime-host/src/__tests__/managed-deployment.test.ts +++ b/packages/runtime-host/src/__tests__/managed-deployment.test.ts @@ -42,6 +42,7 @@ import { type RuntimeHostManagedDeploymentAuthorityOptions, type RuntimeHostManagedDeploymentConfig, } from '../operator/managed-deployment.js'; +import { resolveRuntimeHostNpmDeploymentLayout } from '../operator/update-package-evidence.js'; const DEPLOYMENT_ID = '00000000-0000-4000-8000-000000000001'; const OTHER_DEPLOYMENT_ID = '00000000-0000-4000-8000-000000000002'; @@ -68,25 +69,44 @@ async function fixture(t: test.TestContext): Promise { rm(join(resolveRootOwnershipNamespace(), `${capability.rootId}.lock`), { force: true }), ]), ); + const config = createConfig( + capability.canonicalPath, + capability.rootId, + join(authorityRoot, 'runtime-host'), + process.execPath, + ); + const layout = resolveRuntimeHostNpmDeploymentLayout( + config.deploymentRoot, + config.launch.package.integrity, + ); + await Promise.all([ + mkdir(dirname(layout.cliPath), { recursive: true }), + mkdir(dirname(layout.candidateEntrypoint), { recursive: true }), + ]); + await Promise.all([writeFile(layout.cliPath, ''), writeFile(layout.candidateEntrypoint, '')]); return { capability, authority: { authorityRoot, durabilityBoundary: authorityRoot }, - config: createConfig(capability.canonicalPath, capability.rootId), + config, }; } -function createConfig(rootPath: string, rootId: string): RuntimeHostManagedDeploymentConfig { +function createConfig( + rootPath: string, + rootId: string, + deploymentRoot = '/opt/maka/runtime-host', + nodePath = '/usr/bin/node', +): RuntimeHostManagedDeploymentConfig { return { schemaVersion: 1, deploymentId: DEPLOYMENT_ID, configRevision: 1, - deploymentRoot: '/opt/maka/runtime-host', + deploymentRoot, root: { path: rootPath, id: rootId }, projectDirectoryRoots: [{ label: 'projects', path: '/srv/projects' }], launch: { kind: 'exact_package', - nodePath: '/usr/bin/node', - cliPath: '/opt/maka/runtime-host/versions/1.2.3/cli.js', + nodePath, package: { kind: 'npm_registry', version: '1.2.3', @@ -106,6 +126,33 @@ function createConfig(rootPath: string, rootId: string): RuntimeHostManagedDeplo }; } +function launchRequest( + config: RuntimeHostManagedDeploymentConfig | undefined, + lifecycleMode: 'on_demand' | 'supervised', + claim?: ReturnType, +) { + const layout = + config === undefined + ? undefined + : resolveRuntimeHostNpmDeploymentLayout( + config.deploymentRoot, + config.launch.package.integrity, + ); + return { + lifecycleMode, + claim, + processLaunch: { + executablePath: config?.launch.nodePath ?? process.execPath, + entrypointPath: + layout === undefined + ? (process.argv[1] ?? '') + : lifecycleMode === 'on_demand' + ? layout.candidateEntrypoint + : layout.cliPath, + }, + } as const; +} + test('strictly decodes every level of the canonical deployment contract', () => { const config = createConfig('/srv/maka/state', 'a'.repeat(64)); assert.deepEqual(decodeRuntimeHostManagedDeploymentConfig(config), config); @@ -227,8 +274,7 @@ test('launch acquisition atomically joins deployment authorization and State Roo const input = await fixture(t); const unmanagedOwner = await tryAcquireRuntimeHostLaunchOwner( input.capability, - 'on_demand', - undefined, + launchRequest(undefined, 'on_demand'), input.authority, ); assert.ok(unmanagedOwner); @@ -248,7 +294,11 @@ test('launch acquisition atomically joins deployment authorization and State Roo input.authority, ); await assert.rejects( - tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', undefined, input.authority), + tryAcquireRuntimeHostLaunchOwner( + input.capability, + launchRequest(input.config, 'on_demand', undefined), + input.authority, + ), (error: unknown) => error instanceof RuntimeHostManagedDeploymentError && error.code === 'managed_root_requires_operator', @@ -256,8 +306,7 @@ test('launch acquisition atomically joins deployment authorization and State Roo await assert.rejects( tryAcquireRuntimeHostLaunchOwner( input.capability, - 'supervised', - managed.claim, + launchRequest(input.config, 'supervised', managed.claim), input.authority, ), (error: unknown) => @@ -266,8 +315,7 @@ test('launch acquisition atomically joins deployment authorization and State Roo ); const managedOwner = await tryAcquireRuntimeHostLaunchOwner( input.capability, - 'on_demand', - managed.claim, + launchRequest(input.config, 'on_demand', managed.claim), input.authority, ); assert.ok(managedOwner); @@ -278,7 +326,11 @@ test('concurrent install and unmanaged launch cannot both cross the authority bo const input = await fixture(t); const [claimResult, launchResult] = await Promise.allSettled([ claimRuntimeHostManagedDeployment(input.capability, input.config, input.authority), - tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', undefined, input.authority), + tryAcquireRuntimeHostLaunchOwner( + input.capability, + launchRequest(input.config, 'on_demand', undefined), + input.authority, + ), ]); const claimSucceeded = claimResult.status === 'fulfilled'; @@ -308,14 +360,56 @@ test('concurrent managed activations elect exactly one State Root owner', async input.authority, ); const owners = await Promise.all([ - tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', claim, input.authority), - tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', claim, input.authority), + tryAcquireRuntimeHostLaunchOwner( + input.capability, + launchRequest(input.config, 'on_demand', claim), + input.authority, + ), + tryAcquireRuntimeHostLaunchOwner( + input.capability, + launchRequest(input.config, 'on_demand', claim), + input.authority, + ), ]); assert.equal(owners.filter((owner) => owner !== undefined).length, 1); await Promise.all(owners.map((owner) => owner?.close())); }); +test('managed launch ownership is bound to the configured exact package', async (t) => { + const input = await fixture(t); + const { claim } = await claimRuntimeHostManagedDeployment( + input.capability, + input.config, + input.authority, + ); + + await assert.rejects( + tryAcquireRuntimeHostLaunchOwner( + input.capability, + { + ...launchRequest(input.config, 'on_demand', claim), + processLaunch: { + executablePath: input.config.launch.nodePath, + entrypointPath: '/opt/maka/runtime-host/versions/unclaimed/candidate.js', + }, + }, + input.authority, + ), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && + error.code === 'deployment_launch_mismatch', + ); + + const owner = await tryAcquireRuntimeHostLaunchOwner( + input.capability, + launchRequest(input.config, 'on_demand', claim), + input.authority, + ); + assert.ok(owner); + await owner.close(); +}); + test('managed ownership survives deletion of the disposable control cache', { skip: process.platform === 'win32' ? 'Windows does not unlink an open native lock file' : false, }, async (t) => { @@ -327,15 +421,18 @@ test('managed ownership survives deletion of the disposable control cache', { ); const owner = await tryAcquireRuntimeHostLaunchOwner( input.capability, - 'on_demand', - claim, + launchRequest(input.config, 'on_demand', claim), input.authority, ); assert.ok(owner); await rm(owner.controlDirectory, { recursive: true, force: true }); assert.equal( - await tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', claim, input.authority), + await tryAcquireRuntimeHostLaunchOwner( + input.capability, + launchRequest(input.config, 'on_demand', claim), + input.authority, + ), undefined, ); await owner.close(); @@ -380,6 +477,22 @@ test('rejects oversized deployment records before parsing', async (t) => { ); }); +test('rejects deployment records that are not valid UTF-8', async (t) => { + const input = await fixture(t); + const path = resolveRuntimeHostManagedDeploymentConfigPath( + input.capability.rootId, + input.authority, + ); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, Buffer.from([0xc3, 0x28])); + + await assert.rejects( + readRuntimeHostManagedDeploymentConfig(input.capability, input.authority), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && error.code === 'invalid_config', + ); +}); + test('normalizes unreadable deployment records at the launch authority boundary', async (t) => { const input = await fixture(t); const path = resolveRuntimeHostManagedDeploymentConfigPath( @@ -391,8 +504,29 @@ test('normalizes unreadable deployment records at the launch authority boundary' await assert.rejects( tryAcquireRuntimeHostLaunchOwner( input.capability, - 'on_demand', - runtimeHostManagedLaunchClaim(input.config), + launchRequest(input.config, 'on_demand', runtimeHostManagedLaunchClaim(input.config)), + input.authority, + ), + (error: unknown) => + error instanceof RuntimeHostManagedDeploymentError && + error.code === 'deployment_record_invalid', + ); +}); + +test('normalizes a non-directory deployment record ancestor at the launch boundary', async (t) => { + const input = await fixture(t); + await writeFile( + join( + resolveRuntimeHostManagedDeploymentAuthorityRoot(input.authority), + input.capability.rootId, + ), + 'not a directory', + ); + + await assert.rejects( + tryAcquireRuntimeHostLaunchOwner( + input.capability, + launchRequest(input.config, 'on_demand', runtimeHostManagedLaunchClaim(input.config)), input.authority, ), (error: unknown) => @@ -420,7 +554,11 @@ test('keeps transient deployment record I/O retryable at the Candidate boundary' await chmod(path, 0o000); await assert.rejects( - tryAcquireRuntimeHostLaunchOwner(input.capability, 'on_demand', claim, input.authority), + tryAcquireRuntimeHostLaunchOwner( + input.capability, + launchRequest(input.config, 'on_demand', claim), + input.authority, + ), (error: unknown) => { assert.ok(error instanceof RuntimeHostManagedDeploymentError); assert.equal(error.code, 'deployment_io_failed'); diff --git a/packages/runtime-host/src/__tests__/startup-error.test.ts b/packages/runtime-host/src/__tests__/startup-error.test.ts index 8afa512f10..e29022c58c 100644 --- a/packages/runtime-host/src/__tests__/startup-error.test.ts +++ b/packages/runtime-host/src/__tests__/startup-error.test.ts @@ -50,6 +50,13 @@ test('presents an invalid deployment record as requiring repair', () => { assert.match(error.message, /DEPLOYMENT_RECORD_INVALID/u); }); +test('presents an exact-package mismatch as requiring repair or migration', () => { + const error = runtimeHostStartupError('deployment_launch_mismatch'); + assert.ok(error instanceof RuntimeHostPermanentReconnectError); + assert.match(error.message, /exact package/u); + assert.match(error.message, /DEPLOYMENT_LAUNCH_MISMATCH/u); +}); + test('keeps an unresponsive Host retryable and includes bounded diagnostics', () => { const error = runtimeHostStartupError('host_unresponsive', { deadlineMs: 45_000, diff --git a/packages/runtime-host/src/candidate-entry.ts b/packages/runtime-host/src/candidate-entry.ts index 7c6a4c8077..8ea6bcc8f0 100644 --- a/packages/runtime-host/src/candidate-entry.ts +++ b/packages/runtime-host/src/candidate-entry.ts @@ -18,6 +18,7 @@ */ import { generalizedErrorMessage } from '@maka/core/redaction'; +import { fileURLToPath } from 'node:url'; import { candidateStartupFailureExitCode, classifyCandidateStartupFailure, @@ -51,6 +52,7 @@ export interface ExecutionCandidateEntryHooks { */ export async function runExecutionCandidateEntry( argv: readonly string[], + entrypointUrl: string, hooks: ExecutionCandidateEntryHooks = {}, ): Promise { installRuntimeHostLogCapture(); @@ -65,7 +67,13 @@ export async function runExecutionCandidateEntry( startupAttemptId = parsedStartupAttemptId; result = await startExecutionRuntimeHostCandidate( hooks.overrideOptions ? hooks.overrideOptions(options) : options, - hooks.dependencies ?? {}, + { + ...hooks.dependencies, + processLaunch: { + executablePath: process.execPath, + entrypointPath: fileURLToPath(entrypointUrl), + }, + }, ); } catch (error) { const failure = classifyCandidateStartupFailure(error); diff --git a/packages/runtime-host/src/candidate-startup-failure.ts b/packages/runtime-host/src/candidate-startup-failure.ts index c38d37007a..7d7728530c 100644 --- a/packages/runtime-host/src/candidate-startup-failure.ts +++ b/packages/runtime-host/src/candidate-startup-failure.ts @@ -54,6 +54,7 @@ const EXIT_CODE_BY_REASON: Readonly void | Promise; } +export interface RuntimeHostManagedProcessLaunch { + readonly executablePath: string; + readonly entrypointPath: string; +} + +export interface RuntimeHostManagedLaunchRequest { + readonly lifecycleMode: 'on_demand' | 'supervised'; + readonly claim?: RuntimeHostManagedLaunchClaim; + readonly processLaunch: RuntimeHostManagedProcessLaunch; +} + +export function currentRuntimeHostProcessLaunch(): RuntimeHostManagedProcessLaunch { + return { + executablePath: process.execPath, + entrypointPath: process.argv[1] ?? '', + }; +} + export const RUNTIME_HOST_MANAGED_LAUNCH_REJECTIONS = [ 'managed_root_requires_operator', 'deployment_record_missing', 'deployment_claim_mismatch', 'deployment_lifecycle_mismatch', + 'deployment_launch_mismatch', 'deployment_record_invalid', ] as const; @@ -356,12 +382,11 @@ export async function claimRuntimeHostManagedDeployment( export async function tryAcquireRuntimeHostLaunchOwner( capability: StorageRootCapability<'interactive'>, - expectedLifecycleMode: 'on_demand' | 'supervised', - claim: RuntimeHostManagedLaunchClaim | undefined, + request: RuntimeHostManagedLaunchRequest, options: RuntimeHostManagedDeploymentAuthorityOptions = {}, ): Promise | undefined> { const canonicalClaim = - claim === undefined ? undefined : decodeRuntimeHostManagedLaunchClaim(claim); + request.claim === undefined ? undefined : decodeRuntimeHostManagedLaunchClaim(request.claim); const path = resolveRuntimeHostManagedDeploymentConfigPath(capability.rootId, options); const owner = await tryAcquireStateRootOwner(capability); if (!owner) return undefined; @@ -385,7 +410,7 @@ export async function tryAcquireRuntimeHostLaunchOwner( const rejection = runtimeHostManagedLaunchRejection( config, canonicalClaim, - expectedLifecycleMode, + request.lifecycleMode, ); if (rejection !== undefined) { throw new RuntimeHostManagedDeploymentError( @@ -393,6 +418,12 @@ export async function tryAcquireRuntimeHostLaunchOwner( managedLaunchRejectionMessage(rejection), ); } + if (config && !(await matchesManagedProcessLaunch(config, request))) { + throw new RuntimeHostManagedDeploymentError( + 'deployment_launch_mismatch', + managedLaunchRejectionMessage('deployment_launch_mismatch'), + ); + } return owner; } catch (error) { await owner.close(); @@ -466,11 +497,53 @@ function managedLaunchRejectionMessage(rejection: RuntimeHostManagedLaunchReject return 'The Runtime Host launch does not match the managed deployment'; case 'deployment_lifecycle_mismatch': return 'The Runtime Host launch path cannot honor the configured lifecycle'; + case 'deployment_launch_mismatch': + return 'The Runtime Host process was not launched from the configured exact package'; case 'deployment_record_invalid': return 'The Runtime Host managed deployment record is invalid'; } } +async function matchesManagedProcessLaunch( + config: RuntimeHostManagedDeploymentConfig, + request: RuntimeHostManagedLaunchRequest, +): Promise { + const layout = resolveRuntimeHostNpmDeploymentLayout( + config.deploymentRoot, + config.launch.package.integrity, + ); + const expectedEntrypoint = + request.lifecycleMode === 'on_demand' ? layout.candidateEntrypoint : layout.cliPath; + const [actualExecutable, expectedExecutable, actualEntrypoint, canonicalExpectedEntrypoint] = + await Promise.all([ + canonicalLaunchPath(request.processLaunch.executablePath), + canonicalLaunchPath(config.launch.nodePath), + canonicalLaunchPath(request.processLaunch.entrypointPath), + canonicalLaunchPath(expectedEntrypoint), + ]); + return ( + actualExecutable !== undefined && + actualExecutable === expectedExecutable && + actualEntrypoint !== undefined && + actualEntrypoint === canonicalExpectedEntrypoint + ); +} + +async function canonicalLaunchPath(path: string): Promise { + try { + return await realpath(path); + } catch (error) { + if ( + isNodeError(error, 'ENOENT') || + isNodeError(error, 'ENOTDIR') || + isNodeError(error, 'ELOOP') + ) { + return undefined; + } + throw deploymentIo('Unable to verify the Runtime Host managed launch path', error); + } +} + function assertConfigTargetsCapability( config: RuntimeHostManagedDeploymentConfig, capability: StorageRootCapability<'interactive'>, @@ -487,29 +560,30 @@ function assertConfigTargetsCapability( } async function readBoundedJson(path: string): Promise { - let target: Awaited>; + let document: Buffer; try { - target = await lstat(path); + document = await readStableBoundedFile({ + path, + maxBytes: MAX_DOCUMENT_BYTES, + invalidFile: () => + new RuntimeHostManagedDeploymentError( + 'invalid_config', + 'The Runtime Host managed deployment record must be one stable bounded regular file', + ), + }); } catch (error) { if (isNodeError(error, 'ENOENT')) return undefined; + if (error instanceof RuntimeHostManagedDeploymentError) throw error; throw deploymentIo('Unable to inspect the Runtime Host managed deployment record', error); } - if (!target.isFile() || target.isSymbolicLink() || target.size > MAX_DOCUMENT_BYTES) { - throw new RuntimeHostManagedDeploymentError( - 'invalid_config', - 'The Runtime Host managed deployment record must be a bounded regular file', - ); - } let contents: string; try { - contents = await readFile(path, 'utf8'); + contents = new TextDecoder('utf-8', { fatal: true }).decode(document); } catch (error) { - throw deploymentIo('Unable to read the Runtime Host managed deployment record', error); - } - if (Buffer.byteLength(contents, 'utf8') > MAX_DOCUMENT_BYTES) { throw new RuntimeHostManagedDeploymentError( 'invalid_config', - 'The Runtime Host managed deployment record exceeds its size limit', + 'The Runtime Host managed deployment record is not valid UTF-8', + { cause: error }, ); } try { diff --git a/packages/runtime-host/src/operator/update-package-evidence.ts b/packages/runtime-host/src/operator/update-package-evidence.ts index 11f24c4cc4..ae08244a09 100644 --- a/packages/runtime-host/src/operator/update-package-evidence.ts +++ b/packages/runtime-host/src/operator/update-package-evidence.ts @@ -17,6 +17,9 @@ * under the License. */ +import { createHash } from 'node:crypto'; +import { join, resolve } from 'node:path'; + interface ProductReleaseVersion { readonly core: readonly [bigint, bigint, bigint]; readonly prerelease: readonly string[]; @@ -44,6 +47,35 @@ export function isRuntimeHostNpmDeploymentIdentity( ); } +export interface RuntimeHostNpmDeploymentLayout { + readonly packageRoot: string; + readonly cliPath: string; + readonly candidateEntrypoint: string; +} + +export function resolveRuntimeHostNpmDeploymentLayout( + deploymentRoot: string, + integrity: string, +): RuntimeHostNpmDeploymentLayout { + if (!isSha512PackageIntegrity(integrity)) { + throw new TypeError('Expected canonical Runtime Host npm package integrity'); + } + const directory = `registry-${createHash('sha256').update(integrity).digest('hex')}`; + const packageRoot = join(resolve(deploymentRoot), 'versions', directory); + return { + packageRoot, + cliPath: join(packageRoot, 'dist', 'cli.js'), + candidateEntrypoint: join( + packageRoot, + 'node_modules', + '@maka', + 'runtime-host', + 'dist', + 'execution-candidate-main.js', + ), + }; +} + export function isProductReleaseVersion(value: string): boolean { return parseProductReleaseVersion(value) !== undefined; } diff --git a/packages/runtime-host/src/server/candidate.ts b/packages/runtime-host/src/server/candidate.ts index 49fe491fa5..ccf15989bd 100644 --- a/packages/runtime-host/src/server/candidate.ts +++ b/packages/runtime-host/src/server/candidate.ts @@ -19,9 +19,11 @@ import { resolveExistingStorageRoot } from '@maka/storage/root-authority'; import { + currentRuntimeHostProcessLaunch, tryAcquireRuntimeHostLaunchOwner, type RuntimeHostManagedDeploymentAuthorityOptions, type RuntimeHostManagedLaunchClaim, + type RuntimeHostManagedProcessLaunch, } from '../operator/managed-deployment.js'; import type { RuntimeHostCompositionSource } from './host-composition.js'; import { RuntimeHostKernel } from './host-kernel.js'; @@ -39,6 +41,8 @@ export interface InteractiveRuntimeHostCandidateOptions { export interface InteractiveRuntimeHostCandidateDependencies { /** Test-only authority-location override. */ readonly managedDeploymentAuthority?: RuntimeHostManagedDeploymentAuthorityOptions; + /** Test-only process-identity override. Production derives this from the running process. */ + readonly processLaunch?: RuntimeHostManagedProcessLaunch; } export type InteractiveRuntimeHostCandidateResult = @@ -57,8 +61,11 @@ export async function startInteractiveRuntimeHostCandidate( }); const owner = await tryAcquireRuntimeHostLaunchOwner( capability, - 'on_demand', - options.managedLaunchClaim, + { + lifecycleMode: 'on_demand', + claim: options.managedLaunchClaim, + processLaunch: dependencies.processLaunch ?? currentRuntimeHostProcessLaunch(), + }, dependencies.managedDeploymentAuthority, ); if (!owner) return { kind: 'loser' }; diff --git a/packages/runtime-host/src/server/execution-candidate.ts b/packages/runtime-host/src/server/execution-candidate.ts index 2912afa2a1..99e8344958 100644 --- a/packages/runtime-host/src/server/execution-candidate.ts +++ b/packages/runtime-host/src/server/execution-candidate.ts @@ -19,6 +19,7 @@ import { startInteractiveRuntimeHostCandidate, + type InteractiveRuntimeHostCandidateDependencies, type InteractiveRuntimeHostCandidateOptions, type InteractiveRuntimeHostCandidateResult, } from './candidate.js'; @@ -31,12 +32,14 @@ export type ExecutionRuntimeHostCandidateResult = InteractiveRuntimeHostCandidat export type ExecutionRuntimeHostCandidateOptions = InteractiveRuntimeHostCandidateOptions; -export type ExecutionRuntimeHostCandidateDependencies = ExecutionRuntimeHostCompositionDependencies; +export interface ExecutionRuntimeHostCandidateDependencies + extends ExecutionRuntimeHostCompositionDependencies, + InteractiveRuntimeHostCandidateDependencies {} export async function startExecutionRuntimeHostCandidate( options: ExecutionRuntimeHostCandidateOptions, dependencies: ExecutionRuntimeHostCandidateDependencies = {}, ): Promise { const composition = await createExecutionRuntimeHostCompositionSource({}, dependencies); - return startInteractiveRuntimeHostCandidate(options, composition); + return startInteractiveRuntimeHostCandidate(options, composition, dependencies); } diff --git a/packages/runtime-host/src/server/execution-service.ts b/packages/runtime-host/src/server/execution-service.ts index 16e6ef7b2b..244b14fae9 100644 --- a/packages/runtime-host/src/server/execution-service.ts +++ b/packages/runtime-host/src/server/execution-service.ts @@ -23,9 +23,11 @@ import { type ExecutionRuntimeHostCompositionDependencies, } from './execution-composition-factory.js'; import { + currentRuntimeHostProcessLaunch, tryAcquireRuntimeHostLaunchOwner, type RuntimeHostManagedDeploymentAuthorityOptions, type RuntimeHostManagedLaunchClaim, + type RuntimeHostManagedProcessLaunch, } from '../operator/managed-deployment.js'; import { RuntimeHostKernel } from './host-kernel.js'; import { openRuntimeHostAccessAuthority } from './access-authority.js'; @@ -51,6 +53,8 @@ export interface ExecutionRuntimeHostServiceDependencies extends ExecutionRuntimeHostCompositionDependencies { /** Test-only authority-location override. */ readonly managedDeploymentAuthority?: RuntimeHostManagedDeploymentAuthorityOptions; + /** Test-only process-identity override. Production derives this from the running process. */ + readonly processLaunch?: RuntimeHostManagedProcessLaunch; } export class RuntimeHostRootAlreadyOwnedError extends Error { @@ -70,8 +74,11 @@ export async function startExecutionRuntimeHostService( const capability = await resolveStorageRoot({ path: options.rootPath, kind: 'interactive' }); const owner = await tryAcquireRuntimeHostLaunchOwner( capability, - 'supervised', - options.managedLaunchClaim, + { + lifecycleMode: 'supervised', + claim: options.managedLaunchClaim, + processLaunch: dependencies.processLaunch ?? currentRuntimeHostProcessLaunch(), + }, dependencies.managedDeploymentAuthority, ); if (!owner) throw new RuntimeHostRootAlreadyOwnedError(capability.canonicalPath); diff --git a/packages/runtime-host/src/test-only/execution-candidate-e2e-main.ts b/packages/runtime-host/src/test-only/execution-candidate-e2e-main.ts index 4b09dddd17..b2778f3f20 100644 --- a/packages/runtime-host/src/test-only/execution-candidate-e2e-main.ts +++ b/packages/runtime-host/src/test-only/execution-candidate-e2e-main.ts @@ -34,7 +34,7 @@ import { watchDesktopE2eParentProcess, } from './desktop-e2e-execution.js'; -await runExecutionCandidateEntry(process.argv.slice(2), { +await runExecutionCandidateEntry(process.argv.slice(2), import.meta.url, { overrideOptions: (options) => ({ ...options, idleGraceMs: DESKTOP_E2E_IDLE_GRACE_MS }), dependencies: createDesktopE2eExecutionCandidateDependencies(), onWon: (host) => watchDesktopE2eParentProcess(() => host.close()), diff --git a/packages/storage/src/__tests__/marker-file.test.ts b/packages/storage/src/__tests__/marker-file.test.ts index e842b946fa..c0f4158d68 100644 --- a/packages/storage/src/__tests__/marker-file.test.ts +++ b/packages/storage/src/__tests__/marker-file.test.ts @@ -103,7 +103,7 @@ function faultingOpen( let closeFailed = false; const wrapped: MarkerFileHandle = { stat: (options) => handle.stat(options), - readFile: (encoding) => handle.readFile(encoding), + read: (buffer, offset, length, position) => handle.read(buffer, offset, length, position), writeFile: async (data, encoding) => { if (failurePhase === 'write') { await handle.writeFile(data.slice(0, 1), encoding); diff --git a/packages/storage/src/__tests__/stable-storage.test.ts b/packages/storage/src/__tests__/stable-storage.test.ts new file mode 100644 index 0000000000..18066850ba --- /dev/null +++ b/packages/storage/src/__tests__/stable-storage.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { appendFile, lstat, mkdtemp, open, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { readStableBoundedFile } from '../stable-storage.js'; + +async function fixture(t: test.TestContext) { + const directory = await mkdtemp(join(tmpdir(), 'maka-stable-file-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'record.json'); + await writeFile(path, 'data'); + return { directory, path }; +} + +function invalidFile(): Error { + return new Error('invalid stable file'); +} + +test('reads one bounded regular-file snapshot', async (t) => { + const { path } = await fixture(t); + assert.equal( + (await readStableBoundedFile({ path, maxBytes: 4, invalidFile })).toString('utf8'), + 'data', + ); +}); + +test('rejects a file that grows after its initial snapshot', async (t) => { + const { path } = await fixture(t); + await assert.rejects( + readStableBoundedFile( + { path, maxBytes: 4, invalidFile }, + { + open: async (openedPath, flags) => { + const handle = await open(openedPath, flags); + let firstRead = true; + return { + stat: (options) => handle.stat(options), + read: async (buffer, offset, length, position) => { + if (firstRead) { + firstRead = false; + await appendFile(path, '!'); + } + return handle.read(buffer, offset, length, position); + }, + close: () => handle.close(), + }; + }, + }, + ), + /invalid stable file/u, + ); +}); + +test('rejects a symlink instead of following it', { + skip: process.platform === 'win32' ? 'POSIX no-follow semantics are required' : false, +}, async (t) => { + const { directory, path } = await fixture(t); + const link = join(directory, 'record-link.json'); + await symlink(path, link); + await assert.rejects( + readStableBoundedFile({ path: link, maxBytes: 4, invalidFile }), + /invalid stable file/u, + ); +}); + +test('rejects a pathname replaced after opening the file', async (t) => { + const { directory, path } = await fixture(t); + const replacement = join(directory, 'replacement.json'); + await writeFile(replacement, 'next'); + let observations = 0; + + await assert.rejects( + readStableBoundedFile( + { path, maxBytes: 4, invalidFile }, + { + lstat: async (observedPath, options) => { + observations += 1; + if (observations === 2) await rename(replacement, path); + return lstat(observedPath, options); + }, + }, + ), + /invalid stable file/u, + ); +}); diff --git a/packages/storage/src/marker-file.ts b/packages/storage/src/marker-file.ts index e7a66da035..1f87d66307 100644 --- a/packages/storage/src/marker-file.ts +++ b/packages/storage/src/marker-file.ts @@ -18,13 +18,12 @@ */ import { randomUUID } from 'node:crypto'; -import fs, { constants as fsConstants, type BigIntStats } from 'node:fs'; -import { link, lstat, rename, unlink } from 'node:fs/promises'; +import fs from 'node:fs'; +import { link, rename, unlink } from 'node:fs/promises'; import { join } from 'node:path'; +import { readStableBoundedFile, type StableBoundedFileHandle } from './stable-storage.js'; -export interface MarkerFileHandle { - stat(options: { bigint: true }): Promise; - readFile(encoding: 'utf8'): Promise; +export interface MarkerFileHandle extends StableBoundedFileHandle { writeFile(data: string, encoding: 'utf8'): Promise; sync(): Promise; close(): Promise; @@ -55,25 +54,8 @@ export async function readBoundedMarkerFile( dependencies: Partial = {}, ): Promise { const deps = { ...defaultDependencies, ...dependencies }; - const handle = await deps.open(input.path, markerReadFlags()); - try { - const [handleStat, pathStat] = await Promise.all([ - handle.stat({ bigint: true }), - lstat(input.path, { bigint: true }), - ]); - if ( - !handleStat.isFile() || - !pathStat.isFile() || - handleStat.size > BigInt(input.maxBytes) || - handleStat.dev !== pathStat.dev || - handleStat.ino !== pathStat.ino - ) { - throw input.invalidFile(); - } - return await handle.readFile('utf8'); - } finally { - await handle.close(); - } + const contents = await readStableBoundedFile(input, { open: deps.open }); + return contents.toString('utf8'); } export interface PublishMarkerFileInput { @@ -147,11 +129,6 @@ async function syncDirectory(path: string, deps: MarkerFileDependencies): Promis } } -function markerReadFlags(): string | number { - if (process.platform === 'win32') return 'r'; - return fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW; -} - function isNodeError(error: unknown, code: string): boolean { return ( error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code diff --git a/packages/storage/src/stable-storage.ts b/packages/storage/src/stable-storage.ts index 21feb37a53..45081ca9b1 100644 --- a/packages/storage/src/stable-storage.ts +++ b/packages/storage/src/stable-storage.ts @@ -17,9 +17,76 @@ * under the License. */ -import { open } from 'node:fs/promises'; +import { constants, type BigIntStats } from 'node:fs'; +import { lstat, open } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +export interface ReadStableBoundedFileInput { + readonly path: string; + readonly maxBytes: number; + invalidFile(): Error; +} + +export interface StableBoundedFileHandle { + stat(options: { bigint: true }): Promise; + read( + buffer: TBuffer, + offset?: number, + length?: number, + position?: number | null, + ): Promise<{ bytesRead: number; buffer: TBuffer }>; + close(): Promise; +} + +export interface ReadStableBoundedFileDependencies { + open(path: string, flags: string | number): Promise; + lstat(path: string, options: { bigint: true }): Promise; +} + +const openStableFile = open; +const lstatStableFile = lstat; +const defaultReadDependencies: ReadStableBoundedFileDependencies = { + open: openStableFile, + lstat: lstatStableFile, +}; + +/** Reads one immutable regular-file snapshot without trusting its pathname or declared size. */ +export async function readStableBoundedFile( + input: ReadStableBoundedFileInput, + dependencies: Partial = {}, +): Promise { + if (!Number.isSafeInteger(input.maxBytes) || input.maxBytes < 0) { + throw new RangeError('maxBytes must be a non-negative safe integer'); + } + const deps = { ...defaultReadDependencies, ...dependencies }; + let handle: StableBoundedFileHandle; + try { + handle = await deps.open(input.path, stableReadFlags()); + } catch (error) { + if (isInvalidStableFileError(error)) throw input.invalidFile(); + throw error; + } + try { + const initial = await stableFileSnapshot(handle, input, deps); + const bytes = Buffer.allocUnsafe(input.maxBytes + 1); + let offset = 0; + while (offset < bytes.length) { + const result = await handle.read(bytes, offset, bytes.length - offset, offset); + if (result.bytesRead === 0) break; + offset += result.bytesRead; + } + if (offset > input.maxBytes) throw input.invalidFile(); + + const final = await stableFileSnapshot(handle, input, deps); + if (!sameStableFileSnapshot(initial, final) || BigInt(offset) !== initial.size) { + throw input.invalidFile(); + } + return bytes.subarray(0, offset); + } finally { + await handle.close(); + } +} + export async function syncFile(path: string): Promise { // Windows rejects fsync on a read-only handle (EPERM). Durable store files // are writer-owned, so reopen the existing file read/write without creating @@ -64,3 +131,58 @@ export async function syncDirectory(path: string): Promise { await handle.close(); } } + +async function stableFileSnapshot( + handle: StableBoundedFileHandle, + input: ReadStableBoundedFileInput, + dependencies: ReadStableBoundedFileDependencies, +): Promise { + let handleStat: BigIntStats; + let pathStat: BigIntStats; + try { + [handleStat, pathStat] = await Promise.all([ + handle.stat({ bigint: true }), + dependencies.lstat(input.path, { bigint: true }), + ]); + } catch (error) { + if (isNodeError(error, 'ENOENT') || isInvalidStableFileError(error)) { + throw input.invalidFile(); + } + throw error; + } + if ( + !handleStat.isFile() || + !pathStat.isFile() || + handleStat.size > BigInt(input.maxBytes) || + handleStat.dev !== pathStat.dev || + handleStat.ino !== pathStat.ino + ) { + throw input.invalidFile(); + } + return handleStat; +} + +function sameStableFileSnapshot(left: BigIntStats, right: BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeNs === right.mtimeNs + ); +} + +function stableReadFlags(): string | number { + return process.platform === 'win32' + ? constants.O_RDONLY + : constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW; +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} + +function isInvalidStableFileError(error: unknown): boolean { + return ( + isNodeError(error, 'ELOOP') || isNodeError(error, 'ENOTDIR') || isNodeError(error, 'ENXIO') + ); +} From 89d8ca8ef62cd6ee9692e573a482089609fb9afd Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Thu, 27 Aug 2026 21:37:45 +0800 Subject: [PATCH 07/11] feat(runtime-host): complete SSH on-demand activation Deliver exact-package on-demand installation, stable framed operator activation, authenticated ephemeral WebSocket listeners, and one shared activation-aware SSH profile connector for CLI and Desktop. Generated-by: OpenAI Codex --- .../__tests__/runtime-host-onboarding.test.ts | 15 +- .../runtime-host-ssh-terminal.test.ts | 38 ++- apps/desktop/src/main/runtime-host-boot.ts | 1 + .../main/runtime-host-desktop-candidate.ts | 8 + .../src/main/runtime-host-onboarding.ts | 29 +- .../src/main/runtime-host-ssh-terminal.ts | 54 +++- docs/windows-test-inventory.md | 8 +- .../runtime-host-cli-context.test.ts | 4 +- .../runtime-host-operator-command.test.ts | 40 +++ .../runtime-host-profile-command.test.ts | 6 +- .../runtime-host-service-manager.test.ts | 13 + .../src/__tests__/runtime-host-setup.test.ts | 123 +++++++++ packages/cli/src/cli-core.ts | 8 + .../src/runtime-host-activation-command.ts | 65 +++++ packages/cli/src/runtime-host-cli.ts | 46 +++- .../src/runtime-host-managed-deployment.ts | 4 + .../cli/src/runtime-host-setup-command.ts | 243 ++++++++++++++++- .../src/__tests__/activation-frame.test.ts | 69 +++++ .../__tests__/fixtures/kernel-candidate.ts | 2 +- .../src/__tests__/host-kernel.test.ts | 6 +- .../src/__tests__/host-profile.test.ts | 169 +++++++++++- .../src/__tests__/managed-activation.test.ts | 249 ++++++++++++++++++ .../src/__tests__/managed-deployment.test.ts | 22 +- .../src/__tests__/protocol.test.ts | 35 +++ .../__tests__/ssh-operator-activation.test.ts | 140 ++++++++++ .../runtime-host/src/client/host-profile.ts | 140 ++++++++-- packages/runtime-host/src/client/index.ts | 11 + .../src/client/managed-activation.ts | 208 +++++++++++++++ .../src/client/ssh-operator-activation.ts | 222 ++++++++++++++++ .../src/operator/activation-frame.ts | 103 ++++++++ packages/runtime-host/src/operator/index.ts | 12 + .../src/operator/managed-deployment.ts | 108 +++++++- packages/runtime-host/src/protocol/index.ts | 37 +++ packages/runtime-host/src/server/candidate.ts | 55 +++- .../src/server/execution-candidate.ts | 11 +- .../src/server/execution-service.ts | 11 +- .../runtime-host/src/server/host-kernel.ts | 3 + .../runtime-host/src/server/listener-set.ts | 2 +- 38 files changed, 2212 insertions(+), 108 deletions(-) create mode 100644 packages/cli/src/runtime-host-activation-command.ts create mode 100644 packages/runtime-host/src/__tests__/activation-frame.test.ts create mode 100644 packages/runtime-host/src/__tests__/managed-activation.test.ts create mode 100644 packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts create mode 100644 packages/runtime-host/src/client/managed-activation.ts create mode 100644 packages/runtime-host/src/client/ssh-operator-activation.ts create mode 100644 packages/runtime-host/src/operator/activation-frame.ts diff --git a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts index c05f8b6052..6f3c4c9f46 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts @@ -23,7 +23,7 @@ import type { DesktopRuntimeHostProfileAddInput } from '../../preload/bridge-con import type { DesktopRuntimeHostManagedService } from '../runtime-host-managed-services.js'; import { createDesktopRuntimeHostOnboarding } from '../runtime-host-onboarding.js'; -test('persists a verified SSH profile without projecting its credential', async () => { +test('persists a verified on-demand SSH profile without endpoint or credential projection', async () => { let setupInput: unknown; let saved: | (DesktopRuntimeHostProfileAddInput & { @@ -62,19 +62,18 @@ test('persists a verified SSH profile without projecting its credential', async assert.deepEqual(saved?.profile.transport, { kind: 'ssh', destination: 'operator@example.com', - remotePort: 7443, - websocketPath: '/runtime-host', - }); - assert.deepEqual(saved?.managedService, { - id: 'b'.repeat(64), - rootPath: '/home/operator/.config/Maka/workspaces/default', - operatorPath: '/home/operator/.local/share/maka/operator', + activation: { + kind: 'ssh_operator', + operatorPath: '/home/operator/.local/share/maka/operator', + }, }); + assert.equal(saved?.managedService, undefined); assert.equal(saved?.credential, 'secret-access-token'); assert.deepEqual( (setupInput as { projectDirectoryRoots?: unknown }).projectDirectoryRoots, [{ label: 'Work', path: '/srv/work' }], ); + assert.equal((setupInput as { lifecycle?: unknown }).lifecycle, 'on_demand'); assert.doesNotMatch(JSON.stringify(harness.events), /secret-access-token/u); await harness.onboarding.close(); assert.equal(harness.handlers.size, 0); diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index ea66dedbe9..93e52e761b 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -23,10 +23,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import type { IPty } from 'node-pty'; +import { type RuntimeHostSshProcessFactory } from '@maka/runtime-host/client'; import { - type RuntimeHostSshProcessFactory, -} from '@maka/runtime-host/client'; -import { + encodeRuntimeHostActivationFrame, encodeRuntimeHostAccessManagementFrame, encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, @@ -689,6 +688,39 @@ test('does not launch a management process after the terminal owner closes', asy assert.equal(launches.length, 0); }); +test('runs interactive operator activation as one strict framed SSH command', async () => { + const harness = createHarness('pending'); + const rootId = 'a'.repeat(64); + const activation = harness.terminal.activateSshOperator({ + destination: 'operator@example.com', + operatorPath: '/home/operator/.local/share/maka/operator', + rootId, + interaction: 'terminal', + }); + await waitFor(() => harness.pty.hasDataListener()); + harness.pty.emitData( + encodeRuntimeHostActivationFrame({ + schemaVersion: 1, + kind: 'result', + deploymentId: '00000000-0000-4000-8000-000000000001', + configRevision: 1, + rootId, + hostEpoch: 'host-epoch', + pid: 1234, + protocolVersion: 1, + endpoint: { host: '127.0.0.1', port: 43_210, websocketPath: '/runtime-host' }, + }), + ); + harness.pty.exit(0); + + assert.equal((await activation).pid, 1234); + const remoteCommand = harness.launchArgs[0]?.at(-1) ?? ''; + assert.match(remoteCommand, /'activate' '--framed' '--root-id'/u); + assert.match(remoteCommand, new RegExp(rootId, 'u')); + assert.doesNotMatch(remoteCommand, /credential|token/u); + await harness.terminal.close(); +}); + test('uploads a development release archive before running the same remote setup', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'maka-runtime-host-development-package-')); t.after(() => rm(directory, { recursive: true, force: true })); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 2132d23afb..e3aed9b74f 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -842,6 +842,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( console.error("[runtime-host] projection refresh failed:", error), registerClientIpc: registerHostClientIpc, openSshTunnel: runtimeHostSshTerminal.openSshTunnel, + activateSshOperator: runtimeHostSshTerminal.activateSshOperator, }, { upgradePrompts: createRuntimeHostUpgradePrompts( diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 08f5b5f493..4b0b78e23a 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -25,6 +25,7 @@ import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { SessionChangedEvent, SessionChangedReason } from '@maka/core/session'; import type { BotRegistry } from '@maka/runtime/bots'; import { + type RuntimeHostSshOperatorActivationInput, connectOrSpawnRuntimeHost, connectRemoteRuntimeHostProfile, type RuntimeHostSshInteraction, @@ -38,6 +39,7 @@ import { type RemoteRuntimeHostProfile, type CandidateExitDetails, } from "@maka/runtime-host/client"; +import type { RuntimeHostActivationResult } from "@maka/runtime-host/operator"; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, RUNTIME_HOST_PROTOCOL_VERSION, @@ -125,6 +127,9 @@ export interface DesktopRuntimeHostCandidateDeps { readonly openSshTunnel?: ( input: RuntimeHostSshTunnelInput, ) => Promise; + readonly activateSshOperator?: ( + input: RuntimeHostSshOperatorActivationInput, + ) => Promise; readonly createSessionCopyCleanup: (input: { removeSession: (sessionId: string) => Promise; resumeSessionCopy: (input: { @@ -389,6 +394,9 @@ async function startRemoteDesktopRuntimeHostCandidate( }, { ...(input.openSshTunnel ? { openSshTunnel: input.openSshTunnel } : {}), + ...(input.activateSshOperator + ? { activateSshOperator: input.activateSshOperator } + : {}), }); try { return { diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts index 01b458b19c..6a97f2684a 100644 --- a/apps/desktop/src/main/runtime-host-onboarding.ts +++ b/apps/desktop/src/main/runtime-host-onboarding.ts @@ -106,6 +106,7 @@ export function createDesktopRuntimeHostOnboarding(input: { ): Promise => { try { const setupPackage = await input.resolveSetupPackage(signal); + const lifecycle = setupPackage.kind === 'npm' ? 'on_demand' : 'supervised'; signal.throwIfAborted(); publish({ kind: 'running', phase: 'connecting_ssh' }); let commitStarted = false; @@ -123,6 +124,7 @@ export function createDesktopRuntimeHostOnboarding(input: { destination: request.destination, ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), setupPackage, + lifecycle, principalId: `desktop:${input.clientInstanceId}`, ...(request.projectDirectoryRoots ? { projectDirectoryRoots: request.projectDirectoryRoots } @@ -153,16 +155,29 @@ export function createDesktopRuntimeHostOnboarding(input: { kind: 'ssh', destination: request.destination, ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), - remotePort: endpoint.port, - websocketPath: endpoint.websocketPath, + ...(lifecycle === 'on_demand' + ? { + activation: { + kind: 'ssh_operator' as const, + operatorPath: complete.operatorPath, + }, + } + : { + remotePort: endpoint.port, + websocketPath: endpoint.websocketPath, + }), }, }, credential: complete.credential, - managedService: { - id: complete.serviceId, - rootPath: complete.rootPath, - operatorPath: complete.operatorPath, - }, + ...(lifecycle === 'supervised' + ? { + managedService: { + id: complete.serviceId, + rootPath: complete.rootPath, + operatorPath: complete.operatorPath, + }, + } + : {}), }); return publish({ kind: 'complete', diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 8baa1c9aef..d0a7e6ab46 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -26,18 +26,23 @@ import type { IPty } from 'node-pty'; import { spawn as spawnPty } from 'node-pty'; import { terminateProcessTree } from '@maka/runtime/process-tree-terminator'; import { + activateRuntimeHostSshOperator, normalizeRuntimeHostSshDestination, openRuntimeHostSshTunnel, + type RuntimeHostSshOperatorActivationInput, type RuntimeHostSshProcess, type RuntimeHostSshProcessFactory, type RuntimeHostSshTunnel, type RuntimeHostSshTunnelInput, } from '@maka/runtime-host/client'; import { + decodeRuntimeHostActivationFrame, decodeRuntimeHostAccessManagementFrame, decodeRuntimeHostPeerManagementFrame, decodeRuntimeHostServiceManagementFrame, decodeRuntimeHostSetupFrame, + RUNTIME_HOST_ACTIVATION_FRAME_MAX_BYTES, + RUNTIME_HOST_ACTIVATION_FRAME_PREFIX, RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, @@ -47,6 +52,7 @@ import { RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, type RuntimeHostAccessManagementFrame, + type RuntimeHostActivationResult, type RuntimeHostManagedUpdatePolicy, type RuntimeHostPeerManagementAction, type RuntimeHostPeerManagementFrame, @@ -94,6 +100,7 @@ export interface DesktopRuntimeHostSshSetupInput { readonly sshPort?: number; readonly setupPackage: DesktopRuntimeHostSetupPackage; readonly principalId: string; + readonly lifecycle?: 'supervised' | 'on_demand'; readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; readonly signal?: AbortSignal; } @@ -217,11 +224,15 @@ export function createDesktopRuntimeHostSshTerminal(input: { readonly send: (channel: string, event: DesktopRuntimeHostSshTerminalEvent) => void; readonly spawnPty?: typeof spawnPty; readonly openSshTunnel?: typeof openRuntimeHostSshTunnel; + readonly activateSshOperator?: typeof activateRuntimeHostSshOperator; readonly revealDelayMs?: number; readonly managementTimeoutMs?: number; readonly processStopGraceMs?: number; readonly terminateProcessTree?: typeof terminateProcessTree; }): { + activateSshOperator( + input: RuntimeHostSshOperatorActivationInput, + ): Promise; openSshTunnel(input: RuntimeHostSshTunnelInput): Promise; runSetup( input: DesktopRuntimeHostSshSetupInput, @@ -557,6 +568,27 @@ export function createDesktopRuntimeHostSshTerminal(input: { }; return { + activateSshOperator: async (activationInput) => { + if (activationInput.interaction !== 'terminal') { + return (input.activateSshOperator ?? activateRuntimeHostSshOperator)(activationInput); + } + const frame = await runFramedManagement({ + ...activationInput, + remoteCommand: runtimeHostActivationRemoteCommand(activationInput), + prefix: RUNTIME_HOST_ACTIVATION_FRAME_PREFIX, + pendingMaxBytes: RUNTIME_HOST_ACTIVATION_FRAME_MAX_BYTES, + decode: decodeRuntimeHostActivationFrame, + action: 'activate', + frameAction: () => 'activate', + label: 'Remote Runtime Host activation', + timeoutMs: activationInput.timeoutMs, + }); + if (frame.kind === 'error') throw new Error(frame.error.message); + if (frame.rootId !== activationInput.rootId) { + throw new Error('Remote Runtime Host activation returned an inconsistent root'); + } + return frame; + }, openSshTunnel: async (tunnelInput) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); const openSshTunnel = input.openSshTunnel ?? openRuntimeHostSshTunnel; @@ -997,7 +1029,10 @@ async function settlesWithin(promise: Promise, timeoutMs: number): Prom function runtimeHostSetupRemoteCommand( setupPackage: PreparedSetupPackage, - input: Pick, + input: Pick< + DesktopRuntimeHostSshSetupInput, + 'principalId' | 'projectDirectoryRoots' | 'lifecycle' + >, ): string { if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.principalId)) { throw new Error('Runtime Host setup principal is invalid'); @@ -1009,6 +1044,8 @@ function runtimeHostSetupRemoteCommand( input.principalId, '--preset', 'desktop-client', + '--lifecycle', + input.lifecycle === 'on_demand' ? 'on-demand' : 'supervised', '--defer-pairing-commit', ...(input.projectDirectoryRoots === undefined ? [] @@ -1022,6 +1059,21 @@ function runtimeHostSetupRemoteCommand( ]); } +function runtimeHostActivationRemoteCommand( + input: RuntimeHostSshOperatorActivationInput, +): string { + if (!pathPosix.isAbsolute(input.operatorPath)) { + throw new Error('Runtime Host operator path must be absolute'); + } + return [ + input.operatorPath, + 'activate', + '--framed', + '--root-id', + input.rootId, + ].map(quotePosix).join(' '); +} + function runtimeHostServiceManagementRemoteCommand( input: DesktopRuntimeHostSshManagementInput, ): string { diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 1858d0953a..2d3286f36c 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -15,11 +15,11 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| -| windows-backend-gap | 24 | -| portable-candidate | 9 | +| windows-backend-gap | 25 | +| portable-candidate | 10 | | platform-contract | 36 | -Total Windows-excluded declarations: **69** +Total Windows-excluded declarations: **71** ## Inventory @@ -45,6 +45,7 @@ Total Windows-excluded declarations: **69** | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` a non-reading Client overload is isolated to its connection | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` reports one shutdown failure through close and closed while releasing ownership | `process.platform === 'win32'` | | platform-contract | `packages/runtime-host/src/__tests__/host-kernel.test.ts` publishes private POSIX endpoint and registration permissions | `process.platform === 'win32'` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/managed-activation.test.ts` two real managed activations converge on one Host and exit at true idle | `process.platform === 'win32' ? 'requires a POSIX package-entrypoint symlink' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/managed-deployment.test.ts` managed ownership survives deletion of the disposable control cache | `process.platform === 'win32' ? 'Windows does not unlink an open native lock file' : false` | | platform-contract | `packages/runtime-host/src/__tests__/managed-deployment.test.ts` keeps transient deployment record I/O retryable at the Candidate boundary | `process.platform === 'win32' ? 'POSIX file permissions are required to make the record unreadable' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/memory-two-client-uds.test.ts` two UDS clients share one recoverable Memory authority across Host death | `process.platform === 'win32' ? 'POSIX process death gate' : false` | @@ -91,6 +92,7 @@ Total Windows-excluded declarations: **69** | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` preserves unknown commit semantics and consumes the completion ticket | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` successor recovery removes credentials orphaned by an interrupted connection removal | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` fails closed on final symlinks, FIFOs, and oversized documents without changing bytes | `process.platform === 'win32'` | +| portable-candidate | `packages/storage/src/__tests__/stable-storage.test.ts` rejects a symlink instead of following it | `process.platform === 'win32' ? 'POSIX no-follow semantics are required' : false` | | platform-contract | `packages/storage/src/__tests__/usage-stores.test.ts` classifies a renamed or replaced live root as a draining persistence failure | `process.platform === 'win32' ? 'Windows does not permit renaming a directory with an open SQLite database' : false` | | platform-contract | `packages/storage/src/__tests__/workspace-identity.test.ts` an unmarked read-only workspace fails without leaving marker state | `process.platform === 'win32' ? 'POSIX permissions are required to create a read-only workspace fixture' : false` | | portable-candidate | `scripts/release-cli-eval-support.test.mjs` preserves the primary process failure when diagnostics cannot be read | `process.platform === 'win32'` | diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index 734041d325..5154073561 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -263,7 +263,7 @@ test('remote CLI profiles pin root identity and resolve credential outside the p }, profileCatalog: { read: async () => ({ - schemaVersion: 1, + schemaVersion: 2, profiles: [ { id: 'office', @@ -545,7 +545,7 @@ function incompatibleRemoteHandshake(overrides: Partial = {}): function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog { return { - read: async () => ({ schemaVersion: 1, profiles: [profile] }), + read: async () => ({ schemaVersion: 2, profiles: [profile] }), resolve: async (profileId) => { assert.equal(profileId, profile.id); return { profile, credential: 'opaque-token' }; diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index e5acb1b58d..b11796ec66 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -21,12 +21,14 @@ import assert from 'node:assert/strict'; import { resolve } from 'node:path'; import { describe, test } from 'node:test'; import type { RuntimeHostConnection } from '@maka/runtime-host/client'; +import { decodeRuntimeHostActivationFrame } from '@maka/runtime-host/operator'; import { HOST_OPERATION_SPECS, REMOTE_OWNER_OPERATION_GRANTS, RUNTIME_HOST_COMPATIBILITY_EPOCH, RUNTIME_HOST_PROTOCOL_VERSION, } from '@maka/runtime-host/protocol'; +import { runRuntimeHostManagedActivationCli } from '../runtime-host-activation-command.js'; import { resolveRuntimeHostAccessIssue, type RuntimeHostAccessIssueOptions, @@ -39,6 +41,44 @@ const projectRootA = process.platform === 'win32' ? 'C:\\srv\\projects' : '/srv/ const projectRootB = process.platform === 'win32' ? 'D:\\data' : '/mnt/data'; describe('Runtime Host operator commands', () => { + test('parses and emits the stable framed managed activation contract', async () => { + const rootId = 'a'.repeat(64); + assert.deepEqual(parseRuntimeHostCommand(['activate', '--framed', '--root-id', rootId]), { + kind: 'runtime-host-managed-activate', + rootId, + framed: true, + }); + assert.equal(parseRuntimeHostCommand(['activate', '--root-id', rootId]).kind, 'error'); + + let output = ''; + assert.equal( + await runRuntimeHostManagedActivationCli( + { rootId }, + { + activate: async () => ({ + schemaVersion: 1, + kind: 'result', + deploymentId: '00000000-0000-4000-8000-000000000001', + configRevision: 1, + rootId, + hostEpoch: 'host-epoch', + pid: 1234, + protocolVersion: RUNTIME_HOST_PROTOCOL_VERSION, + endpoint: { + host: '127.0.0.1', + port: 43_210, + websocketPath: '/runtime-host', + }, + }), + writeOutput: (value) => { + output += value; + }, + }, + ), + 0, + ); + assert.equal(decodeRuntimeHostActivationFrame(output)?.kind, 'result'); + }); test('parses project management and machine-readable service readiness', () => { assert.deepEqual(parseRuntimeHostCommand(['project', 'list', '--root', '/srv/maka']), { kind: 'runtime-host-project-list', diff --git a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts index ec7d89ca04..dcb92bf9ab 100644 --- a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts @@ -214,8 +214,8 @@ function createProfileCatalogCapture(): { catalog: RuntimeHostProfileCatalog; saved: Array<{ profile: RemoteRuntimeHostProfile; credential?: string }>; } { - const state = { - document: { schemaVersion: 1, profiles: [] } as RuntimeHostProfileDocument, + const state: { document: RuntimeHostProfileDocument } = { + document: { schemaVersion: 2, profiles: [] }, }; const saved: Array<{ profile: RemoteRuntimeHostProfile; credential?: string }> = []; const catalog: RuntimeHostProfileCatalog = { @@ -225,7 +225,7 @@ function createProfileCatalogCapture(): { save: async (profile: RemoteRuntimeHostProfile, credential?: string) => { saved.push({ profile, credential }); state.document = { - schemaVersion: 1, + schemaVersion: 2, profiles: [ ...state.document.profiles.filter((candidate) => candidate.id !== profile.id), profile, 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 81225ffb21..5e45568422 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -468,12 +468,25 @@ describe('managed Runtime Host service', () => { principalId: 'desktop.client-1', preset: 'desktop-client', clientDataRoot: '/var/lib/maka-client', + lifecycle: 'supervised', deferPairingCommit: true, directPeer: { coordinationRelays: ['/dns4/discovery.example/udp/443/quic-v1'], }, }, ); + assert.equal( + parseRuntimeHostCommand([ + 'setup', + '--principal', + 'desktop.client-1', + '--preset', + 'desktop-client', + '--lifecycle', + 'on-demand', + ]).kind, + 'runtime-host-setup', + ); }); it('applies Project roots as a compare-and-set transaction and restores failed changes', async (t) => { diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 2c467a394c..5a4e5a7dde 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -37,8 +37,13 @@ import { promisify } from 'node:util'; import { decodeRuntimeHostSetupFrame, encodeRuntimeHostSetupFrame, + resolveRuntimeHostManagedDeploymentConfigPath, RUNTIME_HOST_SETUP_FRAME_PREFIX, } from '@maka/runtime-host/operator'; +import { + resolveRootControlNamespace, + resolveRootOwnershipNamespace, +} from '@maka/storage/root-authority'; import { prepareRuntimeHostManagedPackageDeployment, resolveRuntimeHostManagedDeploymentRoot, @@ -55,6 +60,113 @@ import { const execFile = promisify(execFileCallback); const PACKAGE_INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString('base64')}`; +test('on-demand setup installs one exact deployment without a service backend', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-on-demand-setup-')); + const stateRoot = join(base, 'state'); + const clientDataRoot = join(base, 'client'); + const outputs: string[] = []; + let rootId = ''; + let prepareCount = 0; + let openCount = 0; + t.after(async () => { + await Promise.all([ + rm(base, { recursive: true, force: true }), + rootId + ? rm(dirname(resolveRuntimeHostManagedDeploymentConfigPath(rootId)), { + recursive: true, + force: true, + }) + : Promise.resolve(), + rootId + ? rm(join(resolveRootControlNamespace(), rootId), { recursive: true, force: true }) + : Promise.resolve(), + rootId + ? rm(join(resolveRootOwnershipNamespace(), `${rootId}.lock`), { force: true }) + : Promise.resolve(), + ]); + }); + + const options = { + json: true, + lifecycle: 'on_demand', + clientDataRoot, + defaultRootPath: stateRoot, + sourcePackageRoot: base, + version: '1.2.3', + principalId: 'desktop:client-1', + preset: 'desktop-client', + } as const; + const deployment = (serviceId: string) => ({ + version: '1.2.3', + root: resolveRuntimeHostManagedDeploymentRoot(serviceId), + cliPath: '/verified/package/dist/cli.js', + operatorPath: '/opt/maka/operator', + activate: async () => undefined, + cleanup: async () => undefined, + rollback: async () => undefined, + }); + const overrides = { + createBackend: () => assert.fail('on-demand setup must not create a service backend'), + manageService: async () => assert.fail('on-demand setup must not manage a service'), + resolveRegistryCandidate: async () => ({ + kind: 'npm_registry', + version: '1.2.3', + integrity: PACKAGE_INTEGRITY, + }), + withRegistryPackage: async (_candidate, use) => use('/verified/package'), + prepareDeployment: async (input) => { + prepareCount += 1; + return deployment(input.serviceId); + }, + openDeployment: async (input) => { + openCount += 1; + return deployment(input.serviceId); + }, + activateManaged: async (input) => { + rootId = input.rootId; + return { + schemaVersion: 1, + kind: 'result', + deploymentId: `${rootId.slice(0, 8)}-${rootId.slice(8, 12)}-4${rootId.slice(13, 16)}-8${rootId.slice(17, 20)}-${rootId.slice(20, 32)}`, + configRevision: 1, + rootId, + hostEpoch: 'host-epoch', + pid: 1234, + protocolVersion: 1, + endpoint: { host: '127.0.0.1', port: 43_210, websocketPath: '/runtime-host' }, + }; + }, + replaceCredential: async () => ({ + rootId, + credential: 'secret-token', + credentialId: 'credential-1', + principalKind: 'remote_owner' as const, + principalId: 'desktop:client-1', + operationGrants: [] as const, + canPublishClientCapabilities: false, + canUseHostPaths: false, + }), + verifyCredential: async ({ endpoint, rootId: expectedRootId }) => { + assert.equal(endpoint, 'ws://127.0.0.1:43210/runtime-host'); + assert.equal(expectedRootId, rootId); + }, + writeOutput: (value) => outputs.push(value), + } satisfies NonNullable[1]>; + assert.equal(await runRuntimeHostSetupCli(options, overrides), 0); + assert.equal(await runRuntimeHostSetupCli(options, overrides), 0); + assert.equal(prepareCount, 1); + assert.equal(openCount, 1); + const complete = outputs + .map(decodeRuntimeHostSetupFrame) + .find((frame) => frame?.kind === 'complete'); + assert.equal(complete?.kind, 'complete'); + const persisted = JSON.parse( + await readFile(resolveRuntimeHostManagedDeploymentConfigPath(rootId), 'utf8'), + ) as { lifecycle: { mode: string }; listeners: { websocket: { port: number } } }; + assert.equal(persisted.lifecycle.mode, 'on_demand'); + assert.equal(persisted.listeners.websocket.port, 0); +}); + test('managed setup converges on one exact package and verified Client pairing', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-')); t.after(() => rm(base, { recursive: true, force: true })); @@ -541,6 +653,17 @@ test('managed operator binds its Client Data Root and routes deployment cleanup' '--framed', ]); + await execFile(deployment.operatorPath, ['activate', '--framed', '--root-id', 'a'.repeat(64)], { + env: { ...process.env, MAKA_TEST_OUTPUT: invocationPath }, + }); + assert.deepEqual(JSON.parse(await readFile(invocationPath, 'utf8')), [ + 'runtime-host', + 'activate', + '--framed', + '--root-id', + 'a'.repeat(64), + ]); + await execFile( deployment.operatorPath, [ diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 548edbcc76..61ab52fcb6 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -135,6 +135,7 @@ function helpText(cliCommand: string): string { ` ${cliCommand} -p ... Alias for ${cliCommand} run`, ` ${cliCommand} eval ... Run one declarative multi-arm experiment`, ` ${cliCommand} runtime-host serve [options] Run a Runtime Host service`, + ` ${cliCommand} runtime-host activate --framed --root-id `, ` ${cliCommand} runtime-host setup --principal --preset [options]`, ` ${cliCommand} runtime-host service install [options]`, ` ${cliCommand} runtime-host service configure (--project-root