blob: db7accd9e277279a5f187a05ce5479b8e4c4259a [file]
/*
* 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 type { RuntimeHostInstallationOwner } from '@maka/runtime-host/operator';
import { isProductReleaseVersion } from '@maka/runtime-host/operator/update-package-evidence';
const PACKAGE_NAME = 'maka-agent';
const MANIFEST_MAX_BYTES = 64 * 1024;
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<string>;
}
/**
* 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<RuntimeHostCliInstallationDeps> = {},
): Promise<RuntimeHostNpmGlobalInstallation> {
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 = fileURLToPath(new URL('../', import.meta.url)),
input: {
readonly environment: NodeJS.ProcessEnv;
readonly homeDir: string;
readonly platform?: NodeJS.Platform;
} = { environment: process.env, homeDir: homedir() },
): Promise<boolean> {
const canonicalPath = await realpath(path).catch(() => resolve(path));
const defaultCache =
(input.platform ?? process.platform) === 'win32'
? join(input.environment.LOCALAPPDATA || input.homeDir, 'npm-cache')
: join(input.homeDir, '.npm');
const cacheRoots = await Promise.all(
[
input.environment.npm_config_cache ?? input.environment.NPM_CONFIG_CACHE,
defaultCache,
].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<ReturnType<typeof open>>;
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<ReturnType<typeof open>>): Promise<Uint8Array> {
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<string> {
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<string> {
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<string> {
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<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}