diff --git a/packages/cli/src/__tests__/runtime-host-cli-installation.test.ts b/packages/cli/src/__tests__/runtime-host-cli-installation.test.ts new file mode 100644 index 0000000000..130bbf5fa5 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-cli-installation.test.ts @@ -0,0 +1,165 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { test } from 'node:test'; +import { + isTemporaryNpxInstallation, + resolveRuntimeHostNpmGlobalInstallation, + RuntimeHostCliInstallationError, +} from '../runtime-host-cli-installation.js'; + +test('keeps one owner across release changes in the same npm global slot', async (t) => { + const fixture = await installationFixture(t, '1.0.0'); + const first = await resolve(fixture); + await writeFile(fixture.manifestUrl, JSON.stringify({ name: 'maka-agent', version: '2.0.0' })); + const upgraded = await resolve(fixture); + + assert.equal(first.owner.installationId, upgraded.owner.installationId); + assert.deepEqual(first.observedRelease, { + version: '1.0.0', + packageRoot: fixture.packageRoot, + cliPath: fixture.cliPath, + }); + assert.deepEqual(upgraded.observedRelease, { + ...first.observedRelease, + version: '2.0.0', + }); + assert.equal('deployment' in upgraded, false); +}); + +test('uses distinct owners for distinct active npm global roots', async (t) => { + const firstFixture = await installationFixture(t, '1.0.0'); + const secondFixture = await installationFixture(t, '1.0.0'); + const [first, second] = await Promise.all([resolve(firstFixture), resolve(secondFixture)]); + + assert.notEqual(first.owner.installationId, second.owner.installationId); +}); + +test('rejects a package outside the active npm global root', async (t) => { + const fixture = await installationFixture(t, '1.0.0'); + const differentRoot = join(fixture.base, 'other', 'node_modules'); + await mkdir(differentRoot, { recursive: true }); + + await assert.rejects( + resolveRuntimeHostNpmGlobalInstallation( + { manifestUrl: fixture.manifestUrl, homeDir: fixture.homeDir }, + { resolveGlobalNodeModulesRoot: async () => differentRoot }, + ), + installationError('unsupported_installation'), + ); +}); + +test('rejects development and npx packages before treating them as owners', async (t) => { + const development = await installationFixture(t, '1.0.0'); + await writeFile( + development.manifestUrl, + JSON.stringify({ name: 'maka-agent', version: '1.0.0', private: true }), + ); + await assert.rejects(resolve(development), installationError('unsupported_installation')); + + const cacheRoot = await mkdtemp(join(tmpdir(), 'maka-cli-npx-installation-')); + t.after(() => rm(cacheRoot, { recursive: true, force: true })); + const packageRoot = join(cacheRoot, '_npx', 'hash', 'node_modules', 'maka-agent'); + const cliPath = join(packageRoot, 'dist', 'cli.js'); + const manifestUrl = pathToFileURL(join(packageRoot, 'package.json')); + await mkdir(join(packageRoot, 'dist'), { recursive: true }); + await writeFile(cliPath, '#!/usr/bin/env node\n'); + await writeFile(manifestUrl, JSON.stringify({ name: 'maka-agent', version: '1.0.0' })); + let npmRootRequested = false; + + await assert.rejects( + resolveRuntimeHostNpmGlobalInstallation( + { + manifestUrl, + environment: { npm_config_cache: cacheRoot }, + homeDir: join(cacheRoot, 'home'), + }, + { + resolveGlobalNodeModulesRoot: async () => { + npmRootRequested = true; + return dirname(packageRoot); + }, + }, + ), + installationError('unsupported_installation'), + ); + assert.equal(npmRootRequested, false); +}); + +test('recognizes configured and default npx cache roots without prefix collisions', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-cli-npx-provenance-')); + t.after(() => rm(base, { recursive: true, force: true })); + const configured = join(base, 'cache', '_npx', 'one', 'node_modules', 'maka-agent'); + const defaultCache = join(base, 'home', '.npm', '_npx', 'two', 'node_modules', 'maka-agent'); + const collision = join(base, 'cache', '_npx-other', 'maka-agent'); + await Promise.all([ + mkdir(configured, { recursive: true }), + mkdir(defaultCache, { recursive: true }), + mkdir(collision, { recursive: true }), + ]); + const input = { + environment: { npm_config_cache: join(base, 'cache') }, + homeDir: join(base, 'home'), + }; + + assert.equal(await isTemporaryNpxInstallation(configured, input), true); + assert.equal(await isTemporaryNpxInstallation(defaultCache, input), true); + assert.equal(await isTemporaryNpxInstallation(collision, input), false); +}); + +test('rejects non-UTF-8 package metadata instead of normalizing an owner observation', async (t) => { + const fixture = await installationFixture(t, '1.0.0'); + const document = Buffer.from(JSON.stringify({ name: 'maka-agent', version: '1.0.0' })); + document[document.indexOf('1.0.0')] = 0xff; + await writeFile(fixture.manifestUrl, document); + + await assert.rejects(resolve(fixture), installationError('invalid_installation')); +}); + +async function installationFixture(t: test.TestContext, version: string) { + const base = await mkdtemp(join(tmpdir(), 'maka-cli-global-installation-')); + t.after(() => rm(base, { recursive: true, force: true })); + const homeDir = join(base, 'home'); + const globalRoot = join(base, 'lib', 'node_modules'); + const packageRoot = join(globalRoot, 'maka-agent'); + const cliPath = join(packageRoot, 'dist', 'cli.js'); + const manifestUrl = pathToFileURL(join(packageRoot, 'package.json')); + await mkdir(join(packageRoot, 'dist'), { recursive: true }); + await mkdir(homeDir); + await writeFile(cliPath, '#!/usr/bin/env node\n'); + await writeFile(manifestUrl, JSON.stringify({ name: 'maka-agent', version })); + return { base, homeDir, globalRoot, packageRoot, cliPath, manifestUrl }; +} + +function resolve(fixture: Awaited>) { + return resolveRuntimeHostNpmGlobalInstallation( + { manifestUrl: fixture.manifestUrl, homeDir: fixture.homeDir }, + { resolveGlobalNodeModulesRoot: async () => fixture.globalRoot }, + ); +} + +function installationError(code: RuntimeHostCliInstallationError['code']) { + return (error: unknown) => + error instanceof RuntimeHostCliInstallationError && error.code === code; +} diff --git a/packages/cli/src/runtime-host-cli-installation.ts b/packages/cli/src/runtime-host-cli-installation.ts new file mode 100644 index 0000000000..36ad47692f --- /dev/null +++ b/packages/cli/src/runtime-host-cli-installation.ts @@ -0,0 +1,264 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { open, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + isProductReleaseVersion, + type RuntimeHostInstallationOwner, +} from '@maka/runtime-host/operator'; + +const PACKAGE_NAME = 'maka-agent'; +const MANIFEST_MAX_BYTES = 64 * 1024; +const NPM_OUTPUT_MAX_BYTES = 64 * 1024; +const NPM_TIMEOUT_MS = 15_000; + +export class RuntimeHostCliInstallationError extends Error { + constructor( + readonly code: 'invalid_installation' | 'unsupported_installation' | 'npm_unavailable', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RuntimeHostCliInstallationError'; + } +} + +export interface RuntimeHostNpmGlobalInstallation { + readonly owner: RuntimeHostInstallationOwner & { readonly kind: 'cli' }; + /** Mutable local observation, not verified npm artifact identity. */ + readonly observedRelease: { + readonly version: string; + readonly packageRoot: string; + readonly cliPath: string; + }; +} + +interface RuntimeHostCliInstallationDeps { + readonly resolveGlobalNodeModulesRoot: () => Promise; +} + +/** + * Resolves the stable installation slot separately from the mutable package + * currently occupying it. Exact deployment integrity must come from a staged, + * verified registry artifact rather than this observation. + */ +export async function resolveRuntimeHostNpmGlobalInstallation( + options: { + readonly manifestUrl?: URL; + readonly cliPath?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly homeDir?: string; + } = {}, + overrides: Partial = {}, +): Promise { + const manifestUrl = options.manifestUrl ?? new URL('../package.json', import.meta.url); + const homeDir = options.homeDir ?? homedir(); + const environment = options.environment ?? process.env; + let packageRoot: string; + try { + packageRoot = await realpath(fileURLToPath(new URL('.', manifestUrl))); + } catch (cause) { + throw invalidInstallation('The Maka CLI package root is unavailable', cause); + } + if (await isTemporaryNpxInstallation(packageRoot, { environment, homeDir })) { + throw new RuntimeHostCliInstallationError( + 'unsupported_installation', + 'A temporary npx package is not a persistent Runtime Host installation owner', + ); + } + const manifest = await readPackageManifest(manifestUrl); + if (manifest.private === true) { + throw new RuntimeHostCliInstallationError( + 'unsupported_installation', + 'A development checkout is not a persistent npm CLI installation', + ); + } + const cliPath = await canonicalCliPath( + options.cliPath ?? join(packageRoot, 'dist', 'cli.js'), + packageRoot, + ); + const globalRoot = await canonicalGlobalRoot( + await (overrides.resolveGlobalNodeModulesRoot ?? runNpmGlobalRoot)(), + ); + if (packageRoot !== join(globalRoot, PACKAGE_NAME)) { + throw new RuntimeHostCliInstallationError( + 'unsupported_installation', + 'The current Maka CLI is not installed in the active npm global prefix', + ); + } + return { + owner: { + kind: 'cli', + installationId: `npm-global:${createHash('sha256') + .update(globalRoot) + .update('\0') + .update(PACKAGE_NAME) + .digest('hex')}`, + }, + observedRelease: { version: manifest.version, packageRoot, cliPath }, + }; +} + +export async function isTemporaryNpxInstallation( + path: string, + input: { + readonly environment: NodeJS.ProcessEnv; + readonly homeDir: string; + }, +): Promise { + const canonicalPath = await realpath(path).catch(() => resolve(path)); + const cacheRoots = await Promise.all( + [input.environment.npm_config_cache, join(input.homeDir, '.npm')].flatMap((root) => + root ? [realpath(resolve(root, '_npx')).catch(() => resolve(root, '_npx'))] : [], + ), + ); + return cacheRoots.some((root) => isWithin(root, canonicalPath)); +} + +async function readPackageManifest(url: URL): Promise<{ + readonly version: string; + readonly private?: true; +}> { + let handle: Awaited>; + try { + handle = await open( + url, + fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK, + ); + } catch (cause) { + throw invalidInstallation('The Maka CLI package manifest is unavailable', cause); + } + try { + const metadata = await handle.stat(); + if (!metadata.isFile() || metadata.size > MANIFEST_MAX_BYTES) throw new Error('Invalid size'); + const value: unknown = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(await readBoundedManifest(handle)), + ); + if ( + !isRecord(value) || + value.name !== PACKAGE_NAME || + typeof value.version !== 'string' || + !isProductReleaseVersion(value.version) || + (value.private !== undefined && typeof value.private !== 'boolean') + ) { + throw new Error('Invalid package manifest'); + } + return { version: value.version, ...(value.private === true ? { private: true } : {}) }; + } catch (cause) { + throw invalidInstallation('The Maka CLI package manifest is invalid', cause); + } finally { + await handle.close().catch(() => undefined); + } +} + +async function readBoundedManifest(handle: Awaited>): Promise { + const bytes = Buffer.allocUnsafe(MANIFEST_MAX_BYTES + 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 > MANIFEST_MAX_BYTES) throw new Error('Manifest exceeds the byte limit'); + return bytes.subarray(0, offset); +} + +async function canonicalCliPath(path: string, packageRoot: string): Promise { + try { + const canonical = await realpath(path); + if (dirname(dirname(canonical)) !== packageRoot || !(await stat(canonical)).isFile()) { + throw new Error('Invalid CLI entry point'); + } + return canonical; + } catch (cause) { + throw invalidInstallation('The Maka CLI entry point does not belong to its package', cause); + } +} + +async function canonicalGlobalRoot(path: string): Promise { + if (!isAbsolute(path)) { + throw invalidInstallation('npm returned a relative global package root'); + } + try { + const canonical = await realpath(resolve(path)); + if (!(await stat(canonical)).isDirectory()) throw new Error('Not a directory'); + return canonical; + } catch (cause) { + throw invalidInstallation('The npm global package root is unavailable', cause); + } +} + +function runNpmGlobalRoot(): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn('npm', ['root', '--global'], { + cwd: homedir(), + stdio: ['ignore', 'pipe', 'ignore'], + timeout: NPM_TIMEOUT_MS, + killSignal: 'SIGKILL', + }); + let stdout = ''; + let bytes = 0; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + bytes += Buffer.byteLength(chunk, 'utf8'); + if (bytes > NPM_OUTPUT_MAX_BYTES) child.kill('SIGKILL'); + else stdout += chunk; + }); + child.once('error', (cause) => { + reject( + new RuntimeHostCliInstallationError('npm_unavailable', 'Unable to run npm', { cause }), + ); + }); + child.once('close', (code) => { + const lines = stdout.trim().split(/\r?\n/u); + if (code !== 0 || bytes > NPM_OUTPUT_MAX_BYTES || lines.length !== 1 || !lines[0]) { + reject( + new RuntimeHostCliInstallationError( + 'npm_unavailable', + 'Unable to resolve the npm global package root', + ), + ); + return; + } + resolveResult(lines[0]); + }); + }); +} + +function isWithin(root: string, candidate: string): boolean { + const pathFromRoot = relative(root, candidate); + return ( + pathFromRoot === '' || + (pathFromRoot !== '..' && !pathFromRoot.startsWith(`..${sep}`) && !isAbsolute(pathFromRoot)) + ); +} + +function invalidInstallation(message: string, cause?: unknown): RuntimeHostCliInstallationError { + return new RuntimeHostCliInstallationError('invalid_installation', message, { cause }); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index f727eaa754..a14e7aa8f5 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -21,7 +21,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { createServer } from 'node:net'; import { mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { isCanonicalRuntimeHostWebSocketPath, @@ -52,6 +52,7 @@ import { resolveRuntimeHostManagedDeploymentRoot, } from './runtime-host-managed-deployment.js'; import { writeRuntimeHostManagedUpdatePolicy } from './runtime-host-update-policy-store.js'; +import { isTemporaryNpxInstallation } from './runtime-host-cli-installation.js'; const SERVICE_CONFIG_FILE = 'runtime-host-service.json'; const SERVICE_LIFECYCLE_LOCK_FILE = 'runtime-host-setup'; @@ -1024,12 +1025,7 @@ async function assertPersistentCliInstallation( environment: NodeJS.ProcessEnv, homeDir: string, ): Promise { - const cacheRoots = await Promise.all( - [environment.npm_config_cache, join(homeDir, '.npm')].flatMap((root) => - root ? [realpath(resolve(root, '_npx')).catch(() => resolve(root, '_npx'))] : [], - ), - ); - if (cacheRoots.some((root) => isWithin(root, cliPath))) { + if (await isTemporaryNpxInstallation(cliPath, { environment, homeDir })) { throw new RuntimeHostServiceManagerError( 'invalid_launch', 'A persistent Runtime Host service cannot use a temporary npx installation; install Maka globally and retry', @@ -1037,14 +1033,6 @@ async function assertPersistentCliInstallation( } } -function isWithin(root: string, candidate: string): boolean { - const pathFromRoot = relative(root, candidate); - return ( - pathFromRoot === '' || - (pathFromRoot !== '..' && !pathFromRoot.startsWith(`..${sep}`) && !isAbsolute(pathFromRoot)) - ); -} - export async function verifyRuntimeHostManagedServiceReady( config: RuntimeHostManagedServiceConfig, backend: RuntimeHostServiceBackend,