diff --git a/packages/cli/package.json b/packages/cli/package.json index 02e811d92b..3e8dec0df7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,8 +4,7 @@ "license": "Apache-2.0", "private": true, "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "exports": {}, "bin": { "maka": "./dist/cli.js", "maka-agent": "./dist/cli.js" diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 3b1a175d48..43c64c657a 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -7,9 +7,36 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; import { fileURLToPath } from 'node:url'; -import { parseMakaCliArgs, runMakaCli } from '../cli.js'; +import { parseMakaCliArgs, runMakaCli } from '../cli-core.js'; describe('Maka CLI args', () => { + test('declares a bin-only package surface', async () => { + const manifest = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8'), + ) as Record; + assert.deepEqual(manifest.bin, { + maka: './dist/cli.js', + 'maka-agent': './dist/cli.js', + }); + assert.deepEqual(manifest.exports, {}); + assert.equal(Object.hasOwn(manifest, 'main'), false); + assert.equal(Object.hasOwn(manifest, 'types'), false); + await assert.rejects(access(new URL('../index.js', import.meta.url)), { code: 'ENOENT' }); + }); + + test('publishes the supported release command surface', () => { + const help = parseMakaCliArgs(['--help'], '0.1.0'); + assert.equal(help.kind, 'help'); + if (help.kind !== 'help') return; + assert.match(help.text, /^ maka Start the TUI$/m); + assert.match(help.text, /^ maka-agent Start the TUI$/m); + assert.match(help.text, /^ maka run /m); + assert.match(help.text, /^ maka activate /m); + assert.match(help.text, /^ maka eval /m); + assert.match(help.text, /^ maka runtime-host serve /m); + assert.doesNotMatch(help.text, /cli:dev/); + }); + test('selects a Runtime Host and Project for TUI startup', () => { assert.deepEqual(parseMakaCliArgs(['--host', 'office', '--project', 'project-1'], '0.1.0'), { kind: 'tui', @@ -47,7 +74,7 @@ describe('Maka CLI args', () => { }); test('establishes the fatal exit before reporting can throw', async () => { - const cliUrl = new URL('../cli.js', import.meta.url).href; + const cliUrl = new URL('../cli-core.js', import.meta.url).href; const childSource = ` import { handleMakaCliProcessExit } from ${JSON.stringify(cliUrl)}; try { diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index eda9977c3c..cbe93d312d 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -6601,7 +6601,7 @@ async function runSignalExitProbe( stdout: string; }> { const runnerUrl = new URL('../pi-tui-runner.js', import.meta.url).href; - const cliUrl = new URL('../cli.js', import.meta.url).href; + const cliUrl = new URL('../cli-core.js', import.meta.url).href; const terminalUrl = new URL('./tui-terminal-mock.js', import.meta.url).href; const childSource = ` import { runMakaPiTui } from ${JSON.stringify(runnerUrl)}; @@ -6696,7 +6696,7 @@ async function runFatalExitProbe( stderr: string; }> { const runnerUrl = new URL('../pi-tui-runner.js', import.meta.url).href; - const cliUrl = new URL('../cli.js', import.meta.url).href; + const cliUrl = new URL('../cli-core.js', import.meta.url).href; const terminalUrl = new URL('./tui-terminal-mock.js', import.meta.url).href; const trigger = kind === 'uncaughtException' diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts new file mode 100644 index 0000000000..776a3af9c1 --- /dev/null +++ b/packages/cli/src/cli-core.ts @@ -0,0 +1,351 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { formatMakaResumeHint } from './cli-invocation.js'; +import { resolveMakaDataRoots } from './workspace-root.js'; +import { parseRuntimeHostCommand, type RuntimeHostCliCommand } from './runtime-host-cli.js'; + +export type MakaCliCommand = + | { + kind: 'tui'; + resumeSessionId?: string; + resumeCwd?: string; + hostProfileId?: string; + projectId?: string; + } + | { kind: 'run'; args: string[] } + | { kind: 'activate'; args: string[] } + | { kind: 'eval'; args: string[] } + | RuntimeHostCliCommand + | { kind: 'help'; text: string } + | { kind: 'version'; text: string } + | { kind: 'error'; message: string; exitCode: number }; + +export interface MakaCliLaunchOptions { + readonly dataProfileName: string; + readonly cliCommand: string; + readonly capabilityProviderIdentityScope: 'legacy-home' | 'client-data-root'; +} + +export const RELEASE_MAKA_CLI_LAUNCH_OPTIONS = { + dataProfileName: 'Maka', + cliCommand: 'maka', + capabilityProviderIdentityScope: 'legacy-home', +} satisfies MakaCliLaunchOptions; + +export function parseMakaCliArgs( + argv: string[], + version: string, + cliCommand = RELEASE_MAKA_CLI_LAUNCH_OPTIONS.cliCommand, +): MakaCliCommand { + if (argv.length === 0) return { kind: 'tui' }; + const [first] = argv; + if (first === '--help' || first === '-h') return { kind: 'help', text: helpText(cliCommand) }; + if (first === '--version' || first === '-v') return { kind: 'version', text: version }; + if (first?.startsWith('--')) return parseTuiArgs(argv); + if (first === 'run' || first === '-p') return { kind: 'run', args: argv.slice(1) }; + if (first === 'activate') return { kind: 'activate', args: argv.slice(1) }; + if (first === 'eval') return { kind: 'eval', args: argv.slice(1) }; + if (first === 'runtime-host') return parseRuntimeHostCommand(argv.slice(1)); + return { + kind: 'error', + message: `Unexpected argument: ${first ?? ''}`, + exitCode: 2, + }; +} + +export function resolveMakaCliExitCode( + commandExitCode: number, + pendingExitCode: number | string | null | undefined, +): number | string { + return pendingExitCode === undefined || pendingExitCode === null || pendingExitCode === 0 + ? commandExitCode + : pendingExitCode; +} + +export function formatMakaCliFatalError(error: unknown): string { + return error instanceof Error ? (error.stack ?? error.message) : String(error); +} + +let processExitTimer: NodeJS.Timeout | undefined; + +export function beginMakaCliExit(commandExitCode: number): void { + const exitCode = resolveMakaCliExitCode(commandExitCode, process.exitCode); + process.exitCode = exitCode; + if (processExitTimer) return; + processExitTimer = setTimeout(() => process.exit(process.exitCode ?? 0), PROCESS_EXIT_GRACE_MS); + processExitTimer.unref(); +} + +export function handleMakaCliProcessExit( + exitCode: number, + error?: unknown, + writeFatal: (message: string) => unknown = (message) => process.stderr.write(message), +): void { + beginMakaCliExit(exitCode); + if (error) writeFatal(`${formatMakaCliFatalError(error)}\n`); +} + +function helpText(cliCommand: string): string { + return [ + `Usage: ${cliCommand}`, + '', + 'Launches the Maka terminal UI in the current working directory.', + '', + 'Commands:', + ` ${cliCommand} Start the TUI`, + ...(cliCommand === 'maka' ? [' maka-agent Start the TUI'] : []), + ` ${cliCommand} run ... Run one non-interactive model turn`, + ` ${cliCommand} activate ... Run one Cloud Session activation and emit JSONL`, + ` ${cliCommand} -p ... Alias for ${cliCommand} run`, + ` ${cliCommand} eval ... Run one declarative multi-arm experiment`, + ` ${cliCommand} runtime-host serve [options] Run a Runtime Host service`, + ` ${cliCommand} runtime-host access issue --principal --grant `, + ` ${cliCommand} runtime-host access issue --principal --preset `, + ` ${cliCommand} runtime-host access issue --kind capability-provider --principal `, + ` ${cliCommand} runtime-host access revoke --credential `, + ` ${cliCommand} runtime-host project list [--root ]`, + ` ${cliCommand} runtime-host project add [--root ]`, + ` ${cliCommand} runtime-host profile list`, + ` ${cliCommand} runtime-host profile set --id --name --tls-url --expected-root [--credential-env ]`, + ` ${cliCommand} runtime-host profile set --id --name --ssh-destination --ssh-remote-port --expected-root [--ssh-port ] [--credential-env ]`, + ` ${cliCommand} runtime-host profile set --id --name --plaintext-url --acknowledge-plaintext --expected-root [--credential-env ]`, + ` ${cliCommand} runtime-host profile remove --id `, + ` ${cliCommand} runtime-host capability-provider serve --url --mcp-config --expected-root `, + '', + 'Options:', + ' -h, --help Show help', + ' -v, --version Show version', + ' --resume Reopen a previous session in the TUI', + ' --resume --cwd Reopen a session after its directory moved', + ' --host Connect the TUI to a saved Runtime Host profile', + ' --project Select an existing Project on a remote Host', + ' MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL Access credential used by runtime-host profile set', + '', + 'Runtime Host service options:', + ' --root Select the canonical data root', + ' --websocket-port Enable an authenticated WebSocket listener', + ' --websocket-host Bind host (default: 127.0.0.1)', + ' --websocket-path Upgrade path (default: /runtime-host)', + ' --tls-certificate TLS certificate for WSS', + ' --tls-private-key TLS private key for WSS', + ' --allow-insecure-remote Allow plaintext WebSocket access beyond loopback', + ' --allow-origin Allow one browser Origin (repeatable)', + ' --json Emit one machine-readable ready event', + '', + 'Runtime Host access issue options:', + ' --root Select the canonical data root', + ' --kind remote-owner or capability-provider', + ' --principal Name the authenticated Client principal', + ' --grant Grant one exact operation (repeatable)', + ' --preset Grant the desktop-client or terminal-client operation set', + ' --publish-client-capabilities Allow Client Capability publication', + ' --allow-host-paths Allow operations that submit Host paths', + '', + 'Runtime Host capability provider options:', + ' --url Connect to an authenticated Runtime Host WebSocket', + ' --mcp-config Publish tools from an MCP configuration file', + ' --expected-root Pin the canonical Runtime Host root identity', + ' --credential-env Read the access credential from this environment variable', + ' --client-identity Persist the provider Client instance identity here', + ].join('\n'); +} + +export function formatResumeHint( + sessionId: string | null, + cliCommand: string = RELEASE_MAKA_CLI_LAUNCH_OPTIONS.cliCommand, +): string | null { + return formatMakaResumeHint(cliCommand, sessionId); +} + +export async function runMakaCli( + argv: string[] = process.argv.slice(2), + options: MakaCliLaunchOptions = RELEASE_MAKA_CLI_LAUNCH_OPTIONS, +): Promise { + const version = await readPackageVersion(); + const command = parseMakaCliArgs(argv, version, options.cliCommand); + const dataRoots = resolveMakaDataRoots({ profileName: options.dataProfileName }); + switch (command.kind) { + case 'run': { + const { runRuntimeHostTextCli } = await import('./runtime-host-run-command.js'); + return runRuntimeHostTextCli( + command.args, + { workspaceRoot: () => dataRoots.workspaceRoot }, + {}, + { clientDataRoot: dataRoots.clientDataRoot, cliCommand: options.cliCommand }, + ); + } + case 'activate': { + const { runMakaActivationCli } = await import('./activation-command.js'); + return runMakaActivationCli(command.args); + } + case 'eval': { + const { runMakaEvalCli } = await import('@maka/eval'); + return runMakaEvalCli(command.args); + } + case 'runtime-host-serve': { + const { runRuntimeHostServiceCli } = await import('./runtime-host-service-command.js'); + return runRuntimeHostServiceCli({ + rootPath: command.rootPath ?? dataRoots.workspaceRoot, + json: command.json, + ...(command.websocket ? { websocket: command.websocket } : {}), + }); + } + case 'runtime-host-access-issue': { + const { runRuntimeHostAccessIssueCli } = await import('./runtime-host-access-command.js'); + return runRuntimeHostAccessIssueCli({ + rootPath: command.rootPath ?? dataRoots.workspaceRoot, + principalKind: command.principalKind, + principalId: command.principalId, + operationGrants: command.operationGrants, + canPublishClientCapabilities: command.canPublishClientCapabilities, + canUseHostPaths: command.canUseHostPaths, + ...(command.preset ? { preset: command.preset } : {}), + }); + } + case 'runtime-host-access-revoke': { + const { runRuntimeHostAccessRevokeCli } = await import('./runtime-host-access-command.js'); + return runRuntimeHostAccessRevokeCli({ + rootPath: command.rootPath ?? dataRoots.workspaceRoot, + credentialId: command.credentialId, + }); + } + case 'runtime-host-project-list': + case 'runtime-host-project-add': { + const { runRuntimeHostProjectCli } = await import('./runtime-host-project-command.js'); + const rootPath = command.rootPath ?? dataRoots.workspaceRoot; + return command.kind === 'runtime-host-project-list' + ? runRuntimeHostProjectCli({ kind: 'list', rootPath }) + : runRuntimeHostProjectCli({ kind: 'add', rootPath, path: command.path }); + } + case 'runtime-host-capability-provider-serve': { + const { runRuntimeHostCapabilityProviderCli } = await import( + './runtime-host-capability-provider-command.js' + ); + return runRuntimeHostCapabilityProviderCli({ + url: command.url, + mcpConfigPath: command.mcpConfigPath, + expectedRootId: command.expectedRootId, + ...(options.capabilityProviderIdentityScope === 'client-data-root' + ? { + defaultClientIdentityRoot: join( + dataRoots.clientDataRoot, + 'runtime-host-capability-providers', + ), + } + : {}), + ...(command.credentialEnv ? { credentialEnv: command.credentialEnv } : {}), + ...(command.clientIdentityPath ? { clientIdentityPath: command.clientIdentityPath } : {}), + }); + } + case 'runtime-host-profile-list': + case 'runtime-host-profile-set': + case 'runtime-host-profile-remove': { + const { runRuntimeHostProfileCommand } = await import('./runtime-host-profile-command.js'); + const profileOptions = { clientDataRoot: dataRoots.clientDataRoot }; + if (command.kind === 'runtime-host-profile-list') { + return runRuntimeHostProfileCommand({ kind: 'list' }, {}, profileOptions); + } + if (command.kind === 'runtime-host-profile-remove') { + return runRuntimeHostProfileCommand({ kind: 'remove', id: command.id }, {}, profileOptions); + } + return runRuntimeHostProfileCommand( + { + kind: 'set', + id: command.id, + name: command.name, + transport: command.transport, + expectedRootId: command.expectedRootId, + ...(command.credentialEnv ? { credentialEnv: command.credentialEnv } : {}), + }, + {}, + profileOptions, + ); + } + case 'help': + process.stdout.write(`${command.text}\n`); + return 0; + case 'version': + process.stdout.write(`${command.text}\n`); + return 0; + case 'error': + process.stderr.write(`${command.message}\n\n${helpText(options.cliCommand)}\n`); + return command.exitCode; + case 'tui': { + const { runRuntimeHostTui } = await import('./runtime-host-tui-command.js'); + return runRuntimeHostTui({ + cliCommand: options.cliCommand, + clientDataRoot: dataRoots.clientDataRoot, + workspaceRoot: dataRoots.workspaceRoot, + cwd: process.cwd(), + onProcessExit: handleMakaCliProcessExit, + ...(command.resumeSessionId ? { resumeSessionId: command.resumeSessionId } : {}), + ...(command.resumeCwd ? { resumeCwd: command.resumeCwd } : {}), + ...(command.hostProfileId ? { hostProfileId: command.hostProfileId } : {}), + ...(command.projectId ? { projectId: command.projectId } : {}), + }); + } + } +} + +function parseTuiArgs(argv: string[]): MakaCliCommand { + const values = new Map(); + const supported = new Set(['--resume', '--cwd', '--host', '--project']); + for (let index = 0; index < argv.length; index += 1) { + const option = argv[index]; + if (!option || !supported.has(option)) { + return { kind: 'error', message: `Unexpected argument: ${option ?? ''}`, exitCode: 2 }; + } + if (values.has(option)) { + return { kind: 'error', message: `Option repeated: ${option}`, exitCode: 2 }; + } + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + const expected = + option === '--resume' ? 'a session id' : option === '--cwd' ? 'a directory' : 'a value'; + return { kind: 'error', message: `${option} requires ${expected}`, exitCode: 2 }; + } + values.set(option, value); + index += 1; + } + if (values.has('--cwd') && !values.has('--resume')) { + return { kind: 'error', message: '--cwd requires --resume', exitCode: 2 }; + } + if (values.has('--project') && values.has('--resume')) { + return { kind: 'error', message: '--project cannot be used with --resume', exitCode: 2 }; + } + if (values.has('--cwd') && values.has('--host') && values.get('--host') !== 'local') { + return { + kind: 'error', + message: '--cwd cannot be used with a remote Runtime Host', + exitCode: 2, + }; + } + return { + kind: 'tui', + ...(values.has('--resume') ? { resumeSessionId: values.get('--resume') } : {}), + ...(values.has('--cwd') ? { resumeCwd: values.get('--cwd') } : {}), + ...(values.has('--host') ? { hostProfileId: values.get('--host') } : {}), + ...(values.has('--project') ? { projectId: values.get('--project') } : {}), + }; +} + +async function readPackageVersion(): Promise { + const raw = await readFile(new URL('../package.json', import.meta.url), 'utf8'); + const parsed = JSON.parse(raw) as { version?: unknown }; + return typeof parsed.version === 'string' ? parsed.version : '0.0.0'; +} + +export function launchMakaCli(options: MakaCliLaunchOptions): void { + runMakaCli(process.argv.slice(2), options).then( + (code) => { + beginMakaCliExit(code); + }, + (error) => { + handleMakaCliProcessExit(1, error); + }, + ); +} + +// ShellRun escalates SIGTERM to SIGKILL after two seconds. Keep the CLI alive +// long enough for that cleanup to finish before the final process fallback. +const PROCESS_EXIT_GRACE_MS = 3_000; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 91d4085e6c..a35856dde8 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,366 +1,5 @@ #!/usr/bin/env node -import { readFile } from 'node:fs/promises'; -import { realpathSync } from 'node:fs'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { formatMakaResumeHint } from './cli-invocation.js'; -import { resolveMakaDataRoots } from './workspace-root.js'; -import { parseRuntimeHostCommand, type RuntimeHostCliCommand } from './runtime-host-cli.js'; +import { launchMakaCli, RELEASE_MAKA_CLI_LAUNCH_OPTIONS } from './cli-core.js'; -export type MakaCliCommand = - | { - kind: 'tui'; - resumeSessionId?: string; - resumeCwd?: string; - hostProfileId?: string; - projectId?: string; - } - | { kind: 'run'; args: string[] } - | { kind: 'activate'; args: string[] } - | { kind: 'eval'; args: string[] } - | RuntimeHostCliCommand - | { kind: 'help'; text: string } - | { kind: 'version'; text: string } - | { kind: 'error'; message: string; exitCode: number }; - -export interface MakaCliLaunchOptions { - readonly dataProfileName: string; - readonly cliCommand: string; - readonly capabilityProviderIdentityScope: 'legacy-home' | 'client-data-root'; -} - -const RELEASE_MAKA_CLI_LAUNCH_OPTIONS = { - dataProfileName: 'Maka', - cliCommand: 'maka', - capabilityProviderIdentityScope: 'legacy-home', -} satisfies MakaCliLaunchOptions; - -export function parseMakaCliArgs( - argv: string[], - version: string, - cliCommand = RELEASE_MAKA_CLI_LAUNCH_OPTIONS.cliCommand, -): MakaCliCommand { - if (argv.length === 0) return { kind: 'tui' }; - const [first] = argv; - if (first === '--help' || first === '-h') return { kind: 'help', text: helpText(cliCommand) }; - if (first === '--version' || first === '-v') return { kind: 'version', text: version }; - if (first?.startsWith('--')) return parseTuiArgs(argv); - if (first === 'run' || first === '-p') return { kind: 'run', args: argv.slice(1) }; - if (first === 'activate') return { kind: 'activate', args: argv.slice(1) }; - if (first === 'eval') return { kind: 'eval', args: argv.slice(1) }; - if (first === 'runtime-host') return parseRuntimeHostCommand(argv.slice(1)); - return { - kind: 'error', - message: `Unexpected argument: ${first ?? ''}`, - exitCode: 2, - }; -} - -export function resolveMakaCliExitCode( - commandExitCode: number, - pendingExitCode: number | string | null | undefined, -): number | string { - return pendingExitCode === undefined || pendingExitCode === null || pendingExitCode === 0 - ? commandExitCode - : pendingExitCode; -} - -export function formatMakaCliFatalError(error: unknown): string { - return error instanceof Error ? (error.stack ?? error.message) : String(error); -} - -let processExitTimer: NodeJS.Timeout | undefined; - -export function beginMakaCliExit(commandExitCode: number): void { - const exitCode = resolveMakaCliExitCode(commandExitCode, process.exitCode); - process.exitCode = exitCode; - if (processExitTimer) return; - processExitTimer = setTimeout(() => process.exit(process.exitCode ?? 0), PROCESS_EXIT_GRACE_MS); - processExitTimer.unref(); -} - -export function handleMakaCliProcessExit( - exitCode: number, - error?: unknown, - writeFatal: (message: string) => unknown = (message) => process.stderr.write(message), -): void { - beginMakaCliExit(exitCode); - if (error) writeFatal(`${formatMakaCliFatalError(error)}\n`); -} - -function helpText(cliCommand: string): string { - return [ - `Usage: ${cliCommand}`, - '', - 'Launches the Maka terminal UI in the current working directory.', - '', - 'Commands:', - ` ${cliCommand} Start the TUI`, - ...(cliCommand === 'maka' ? [' maka-agent Start the TUI'] : []), - ` ${cliCommand} run ... Run one non-interactive model turn`, - ` ${cliCommand} activate ... Run one Cloud Session activation and emit JSONL`, - ` ${cliCommand} -p ... Alias for ${cliCommand} run`, - ` ${cliCommand} eval ... Run one declarative multi-arm experiment`, - ` ${cliCommand} runtime-host serve [options] Run a Runtime Host service`, - ` ${cliCommand} runtime-host access issue --principal --grant `, - ` ${cliCommand} runtime-host access issue --principal --preset `, - ` ${cliCommand} runtime-host access issue --kind capability-provider --principal `, - ` ${cliCommand} runtime-host access revoke --credential `, - ` ${cliCommand} runtime-host project list [--root ]`, - ` ${cliCommand} runtime-host project add [--root ]`, - ` ${cliCommand} runtime-host profile list`, - ` ${cliCommand} runtime-host profile set --id --name --tls-url --expected-root [--credential-env ]`, - ` ${cliCommand} runtime-host profile set --id --name --ssh-destination --ssh-remote-port --expected-root [--ssh-port ] [--credential-env ]`, - ` ${cliCommand} runtime-host profile set --id --name --plaintext-url --acknowledge-plaintext --expected-root [--credential-env ]`, - ` ${cliCommand} runtime-host profile remove --id `, - ` ${cliCommand} runtime-host capability-provider serve --url --mcp-config --expected-root `, - '', - 'Options:', - ' -h, --help Show help', - ' -v, --version Show version', - ' --resume Reopen a previous session in the TUI', - ' --resume --cwd Reopen a session after its directory moved', - ' --host Connect the TUI to a saved Runtime Host profile', - ' --project Select an existing Project on a remote Host', - ' MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL Access credential used by runtime-host profile set', - '', - 'Runtime Host service options:', - ' --root Select the canonical data root', - ' --websocket-port Enable an authenticated WebSocket listener', - ' --websocket-host Bind host (default: 127.0.0.1)', - ' --websocket-path Upgrade path (default: /runtime-host)', - ' --tls-certificate TLS certificate for WSS', - ' --tls-private-key TLS private key for WSS', - ' --allow-insecure-remote Allow plaintext WebSocket access beyond loopback', - ' --allow-origin Allow one browser Origin (repeatable)', - ' --json Emit one machine-readable ready event', - '', - 'Runtime Host access issue options:', - ' --root Select the canonical data root', - ' --kind remote-owner or capability-provider', - ' --principal Name the authenticated Client principal', - ' --grant Grant one exact operation (repeatable)', - ' --preset Grant the desktop-client or terminal-client operation set', - ' --publish-client-capabilities Allow Client Capability publication', - ' --allow-host-paths Allow operations that submit Host paths', - '', - 'Runtime Host capability provider options:', - ' --url Connect to an authenticated Runtime Host WebSocket', - ' --mcp-config Publish tools from an MCP configuration file', - ' --expected-root Pin the canonical Runtime Host root identity', - ' --credential-env Read the access credential from this environment variable', - ' --client-identity Persist the provider Client instance identity here', - ].join('\n'); -} - -export function formatResumeHint( - sessionId: string | null, - cliCommand: string = RELEASE_MAKA_CLI_LAUNCH_OPTIONS.cliCommand, -): string | null { - return formatMakaResumeHint(cliCommand, sessionId); -} - -export async function runMakaCli( - argv: string[] = process.argv.slice(2), - options: MakaCliLaunchOptions = RELEASE_MAKA_CLI_LAUNCH_OPTIONS, -): Promise { - const version = await readPackageVersion(); - const command = parseMakaCliArgs(argv, version, options.cliCommand); - const dataRoots = resolveMakaDataRoots({ profileName: options.dataProfileName }); - switch (command.kind) { - case 'run': { - const { runRuntimeHostTextCli } = await import('./runtime-host-run-command.js'); - return runRuntimeHostTextCli( - command.args, - { workspaceRoot: () => dataRoots.workspaceRoot }, - {}, - { clientDataRoot: dataRoots.clientDataRoot, cliCommand: options.cliCommand }, - ); - } - case 'activate': { - const { runMakaActivationCli } = await import('./activation-command.js'); - return runMakaActivationCli(command.args); - } - case 'eval': { - const { runMakaEvalCli } = await import('@maka/eval'); - return runMakaEvalCli(command.args); - } - case 'runtime-host-serve': { - const { runRuntimeHostServiceCli } = await import('./runtime-host-service-command.js'); - return runRuntimeHostServiceCli({ - rootPath: command.rootPath ?? dataRoots.workspaceRoot, - json: command.json, - ...(command.websocket ? { websocket: command.websocket } : {}), - }); - } - case 'runtime-host-access-issue': { - const { runRuntimeHostAccessIssueCli } = await import('./runtime-host-access-command.js'); - return runRuntimeHostAccessIssueCli({ - rootPath: command.rootPath ?? dataRoots.workspaceRoot, - principalKind: command.principalKind, - principalId: command.principalId, - operationGrants: command.operationGrants, - canPublishClientCapabilities: command.canPublishClientCapabilities, - canUseHostPaths: command.canUseHostPaths, - ...(command.preset ? { preset: command.preset } : {}), - }); - } - case 'runtime-host-access-revoke': { - const { runRuntimeHostAccessRevokeCli } = await import('./runtime-host-access-command.js'); - return runRuntimeHostAccessRevokeCli({ - rootPath: command.rootPath ?? dataRoots.workspaceRoot, - credentialId: command.credentialId, - }); - } - case 'runtime-host-project-list': - case 'runtime-host-project-add': { - const { runRuntimeHostProjectCli } = await import('./runtime-host-project-command.js'); - const rootPath = command.rootPath ?? dataRoots.workspaceRoot; - return command.kind === 'runtime-host-project-list' - ? runRuntimeHostProjectCli({ kind: 'list', rootPath }) - : runRuntimeHostProjectCli({ kind: 'add', rootPath, path: command.path }); - } - case 'runtime-host-capability-provider-serve': { - const { runRuntimeHostCapabilityProviderCli } = await import( - './runtime-host-capability-provider-command.js' - ); - return runRuntimeHostCapabilityProviderCli({ - url: command.url, - mcpConfigPath: command.mcpConfigPath, - expectedRootId: command.expectedRootId, - ...(options.capabilityProviderIdentityScope === 'client-data-root' - ? { - defaultClientIdentityRoot: join( - dataRoots.clientDataRoot, - 'runtime-host-capability-providers', - ), - } - : {}), - ...(command.credentialEnv ? { credentialEnv: command.credentialEnv } : {}), - ...(command.clientIdentityPath ? { clientIdentityPath: command.clientIdentityPath } : {}), - }); - } - case 'runtime-host-profile-list': - case 'runtime-host-profile-set': - case 'runtime-host-profile-remove': { - const { runRuntimeHostProfileCommand } = await import('./runtime-host-profile-command.js'); - const profileOptions = { clientDataRoot: dataRoots.clientDataRoot }; - if (command.kind === 'runtime-host-profile-list') { - return runRuntimeHostProfileCommand({ kind: 'list' }, {}, profileOptions); - } - if (command.kind === 'runtime-host-profile-remove') { - return runRuntimeHostProfileCommand({ kind: 'remove', id: command.id }, {}, profileOptions); - } - return runRuntimeHostProfileCommand( - { - kind: 'set', - id: command.id, - name: command.name, - transport: command.transport, - expectedRootId: command.expectedRootId, - ...(command.credentialEnv ? { credentialEnv: command.credentialEnv } : {}), - }, - {}, - profileOptions, - ); - } - case 'help': - process.stdout.write(`${command.text}\n`); - return 0; - case 'version': - process.stdout.write(`${command.text}\n`); - return 0; - case 'error': - process.stderr.write(`${command.message}\n\n${helpText(options.cliCommand)}\n`); - return command.exitCode; - case 'tui': { - const { runRuntimeHostTui } = await import('./runtime-host-tui-command.js'); - return runRuntimeHostTui({ - cliCommand: options.cliCommand, - clientDataRoot: dataRoots.clientDataRoot, - workspaceRoot: dataRoots.workspaceRoot, - cwd: process.cwd(), - onProcessExit: handleMakaCliProcessExit, - ...(command.resumeSessionId ? { resumeSessionId: command.resumeSessionId } : {}), - ...(command.resumeCwd ? { resumeCwd: command.resumeCwd } : {}), - ...(command.hostProfileId ? { hostProfileId: command.hostProfileId } : {}), - ...(command.projectId ? { projectId: command.projectId } : {}), - }); - } - } -} - -function parseTuiArgs(argv: string[]): MakaCliCommand { - const values = new Map(); - const supported = new Set(['--resume', '--cwd', '--host', '--project']); - for (let index = 0; index < argv.length; index += 1) { - const option = argv[index]; - if (!option || !supported.has(option)) { - return { kind: 'error', message: `Unexpected argument: ${option ?? ''}`, exitCode: 2 }; - } - if (values.has(option)) { - return { kind: 'error', message: `Option repeated: ${option}`, exitCode: 2 }; - } - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - const expected = - option === '--resume' ? 'a session id' : option === '--cwd' ? 'a directory' : 'a value'; - return { kind: 'error', message: `${option} requires ${expected}`, exitCode: 2 }; - } - values.set(option, value); - index += 1; - } - if (values.has('--cwd') && !values.has('--resume')) { - return { kind: 'error', message: '--cwd requires --resume', exitCode: 2 }; - } - if (values.has('--project') && values.has('--resume')) { - return { kind: 'error', message: '--project cannot be used with --resume', exitCode: 2 }; - } - if (values.has('--cwd') && values.has('--host') && values.get('--host') !== 'local') { - return { - kind: 'error', - message: '--cwd cannot be used with a remote Runtime Host', - exitCode: 2, - }; - } - return { - kind: 'tui', - ...(values.has('--resume') ? { resumeSessionId: values.get('--resume') } : {}), - ...(values.has('--cwd') ? { resumeCwd: values.get('--cwd') } : {}), - ...(values.has('--host') ? { hostProfileId: values.get('--host') } : {}), - ...(values.has('--project') ? { projectId: values.get('--project') } : {}), - }; -} - -async function readPackageVersion(): Promise { - const raw = await readFile(new URL('../package.json', import.meta.url), 'utf8'); - const parsed = JSON.parse(raw) as { version?: unknown }; - return typeof parsed.version === 'string' ? parsed.version : '0.0.0'; -} - -export function launchMakaCli(options: MakaCliLaunchOptions): void { - runMakaCli(process.argv.slice(2), options).then( - (code) => { - beginMakaCliExit(code); - }, - (error) => { - handleMakaCliProcessExit(1, error); - }, - ); -} - -if (isMainModule()) launchMakaCli(RELEASE_MAKA_CLI_LAUNCH_OPTIONS); - -// ShellRun escalates SIGTERM to SIGKILL after two seconds. Keep the CLI alive -// long enough for that cleanup to finish before the final process fallback. -const PROCESS_EXIT_GRACE_MS = 3_000; - -function isMainModule(): boolean { - if (!process.argv[1]) return false; - try { - return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); - } catch { - return false; - } -} +launchMakaCli(RELEASE_MAKA_CLI_LAUNCH_OPTIONS); diff --git a/packages/cli/src/dev-cli.ts b/packages/cli/src/dev-cli.ts index c01ea88e99..f4fe6e4dd5 100644 --- a/packages/cli/src/dev-cli.ts +++ b/packages/cli/src/dev-cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { launchMakaCli } from './cli.js'; +import { launchMakaCli } from './cli-core.js'; launchMakaCli({ dataProfileName: 'Maka Dev', diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts deleted file mode 100644 index 2c248357e7..0000000000 --- a/packages/cli/src/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -export { - type MakaSessionDriver, - type SessionResumeAvailability, -} from './session-driver.js'; -export { - parseMakaCliArgs, - type MakaCliCommand, -} from './cli.js'; -export { - parseMakaRunArgs, - type MakaRunDeps, - type MakaRunOptions, - type ParseMakaRunArgsResult, -} from './run-command-core.js'; -export { - runRuntimeHostTextCli, - runRuntimeHostTextCli as runMakaTextCli, -} from './runtime-host-run-command.js'; -export { - decodeActivationRequest, - parseMakaActivateArgs, - runMakaActivationCli, - type MakaActivationContext, - type MakaActivationBlockedReason, - type MakaActivationDeps, - type MakaActivationOptions, - type MakaActivationRequest, - type MakaActivationRuntime, - type MakaActivationRequiredAction, - type MakaActivationStatus, - type ParseMakaActivateArgsResult, -} from './activation-command.js'; -export { - selectMakaRunSession, - type MakaRunSessionSelection, - type MakaRunSessionSelectionDeps, - type MakaRunSessionSelectionInput, -} from './run-session-selection.js'; -export { - deriveMakaDataRoots, - resolveMakaClientDataRoot, - resolveMakaDataRoots, - resolveMakaWorkspaceRoot, - type DeriveMakaDataRootsInput, - type MakaDataRoots, - type ResolveMakaClientDataRootInput, - type ResolveMakaWorkspaceRootInput, -} from './workspace-root.js'; -export { - runMakaPiTui, - type MakaPiTuiInput, -} from './pi-tui-runner.js'; -export { - appendUserPrompt, - appendTurnFailureToTranscript, - applyMakaSessionEventToTranscript, - createMakaPiTranscriptState, - renderMakaPiTranscript, - type MakaPiTranscriptEntry, - type MakaPiTranscriptMetadata, - type MakaPiTranscriptState, -} from './pi-transcript.js'; diff --git a/packages/eval/README.md b/packages/eval/README.md index 86beb2943b..8875592634 100644 --- a/packages/eval/README.md +++ b/packages/eval/README.md @@ -18,6 +18,8 @@ maka eval run experiment.json --out .maka-eval/run-001 Use `--cell ` to replace one failed or indeterminate cell. The attempt log is append-only and result selection always uses the earliest valid attempt. +Before starting a trial, the public CLI validates the selected executor's machine paths, bundled relay files, pinned Harbor or Pier Python distribution, and Docker daemon availability for Docker environments. A missing or mismatched prerequisite is reported with the configured environment-variable name and expected framework version; the CLI does not start a trial or install external software. Subject-specific toolchain verification remains part of subject preparation and also completes before any trial starts. + The built-in Harbor and Pier executors use one relay Agent. The framework prepares the task environment, the relay invokes exactly one Eval subject from `Agent.run()`, and the framework runs its native verifier and finalizer. Harbor and Pier use separate, explicitly versioned Python environments because their Agent and task contracts differ. Maka subjects ask the Runtime Host client to run one owned execution in a dedicated Host root. Session, Turn, Goal and continuation semantics remain inside Runtime Host. External subjects declare a command and arguments, and may add non-secret environment values, target-to-source bindings for declared credentials, and an explicit result contract. Omitted credential bindings use declared names unchanged. The generic `exit-code` contract discards unstructured stdout and records null usage and cost. The structured `protocol-v1` contract is restricted to the bundled external wrapper so the shared relay can separate a bounded result frame from Harbor/Pier's merged process output; cohort-specific wrappers do not gain Runtime authority. diff --git a/packages/eval/src/__tests__/cli.test.ts b/packages/eval/src/__tests__/cli.test.ts new file mode 100644 index 0000000000..371668796a --- /dev/null +++ b/packages/eval/src/__tests__/cli.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { runMakaEvalCli } from '../cli.js'; + +test('fails install preflight before starting an experiment attempt', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-eval-cli-preflight-')); + t.after(() => rm(root, { recursive: true, force: true })); + const trials = join(root, 'trials'); + await mkdir(trials); + const specPath = join(root, 'experiment.json'); + const outputPath = join(root, 'output'); + const missingMount = join(root, 'missing-toolchain'); + const previous = { + python: process.env.MAKA_TEST_CLI_PYTHON, + trials: process.env.MAKA_TEST_CLI_TRIALS, + mount: process.env.MAKA_TEST_CLI_MOUNT, + }; + process.env.MAKA_TEST_CLI_PYTHON = process.execPath; + process.env.MAKA_TEST_CLI_TRIALS = trials; + process.env.MAKA_TEST_CLI_MOUNT = missingMount; + t.after(() => { + restoreEnvironment('MAKA_TEST_CLI_PYTHON', previous.python); + restoreEnvironment('MAKA_TEST_CLI_TRIALS', previous.trials); + restoreEnvironment('MAKA_TEST_CLI_MOUNT', previous.mount); + }); + await writeFile(specPath, JSON.stringify(experiment())); + let stderr = ''; + + const exitCode = await runMakaEvalCli(['run', specPath, '--out', outputPath], { + stdout: () => assert.fail('preflight failure must not write a result'), + stderr: (text) => { + stderr += text; + }, + }); + + assert.equal(exitCode, 2); + assert.match(stderr, /machine path MAKA_TEST_CLI_MOUNT does not exist/); + assert.deepEqual(await readdir(outputPath), ['experiment.json']); +}); + +function experiment() { + return { + schemaVersion: 'maka.eval.v1', + id: 'preflight', + benchmark: { id: 'benchmark', version: '1', config: {} }, + executor: { + kind: 'harbor', + config: { + frameworkVersion: '0.20.0', + pythonPathEnv: 'MAKA_TEST_CLI_PYTHON', + trialsRootEnv: 'MAKA_TEST_CLI_TRIALS', + environment: { type: 'docker' }, + preparationEnvironment: [], + mounts: [{ sourceEnv: 'MAKA_TEST_CLI_MOUNT', target: '/opt/toolchain', readOnly: true }], + }, + }, + subjects: [{ id: 'subject', kind: 'external', credentials: [], config: {} }], + tasks: [{ id: 'task', input: 'do work', config: {} }], + repetitions: 1, + budget: {}, + verifier: {}, + }; +} + +function restoreEnvironment(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} diff --git a/packages/eval/src/__tests__/install-preflight.test.ts b/packages/eval/src/__tests__/install-preflight.test.ts new file mode 100644 index 0000000000..50fb43e9a3 --- /dev/null +++ b/packages/eval/src/__tests__/install-preflight.test.ts @@ -0,0 +1,345 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { createHarborExecutor } from '../harness-executor.js'; +import type { HarnessPreflightDependencies } from '../install-preflight.js'; +import type { ExperimentSpec } from '../experiment.js'; +import { parseExperimentSpec } from '../spec.js'; + +test('preflights the pinned Python framework and Docker before execution', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-eval-install-preflight-')); + t.after(() => rm(root, { recursive: true, force: true })); + const trials = join(root, 'trials'); + const mount = join(root, 'mount'); + const egress = join(root, 'egress'); + await Promise.all([mkdir(trials), mkdir(mount), mkdir(egress)]); + await Promise.all([ + writeFile(join(egress, 'compose.yaml'), 'services: {}\n'), + writeFile(join(egress, 'network-policy.json'), '{}\n'), + ]); + const restore = setMachinePaths({ + MAKA_TEST_PREFLIGHT_PYTHON: process.execPath, + MAKA_TEST_PREFLIGHT_TRIALS: trials, + MAKA_TEST_PREFLIGHT_MOUNT: mount, + MAKA_TEST_PREFLIGHT_EGRESS: egress, + MAKA_TEST_PREFLIGHT_SECRET: 'must-not-reach-preflight-processes', + }); + t.after(restore); + const calls: { + command: string; + args: readonly string[]; + environment: NodeJS.ProcessEnv; + cwd: string; + }[] = []; + + await preflight(experiment('MAKA_TEST_PREFLIGHT_SECRET', true), join(root, 'spec.json'), { + runCommand: async (command, args, environment, cwd) => { + calls.push({ command, args, environment, cwd }); + }, + }); + + assert.equal(calls.length, 2); + assert.equal(calls[0]?.command, process.execPath); + assert.deepEqual(calls[0]?.args.slice(-3), ['harbor', '0.20.0', 'harbor']); + assert.equal(calls[0]?.environment.MAKA_TEST_PREFLIGHT_SECRET, undefined); + assert.equal(calls[0]?.environment.MAKA_EVAL_EGRESS_REQUIRED, '1'); + assert.equal(calls[0]?.environment.MAKA_EVAL_EGRESS_ALLOWED_HOST, 'api.example.test'); + assert.equal( + calls[0]?.environment.MAKA_EVAL_NETWORK_POLICY_PATH, + join(egress, 'network-policy.json'), + ); + assert.deepEqual(calls[1], { + command: 'docker', + args: ['version', '--format', '{{.Server.Version}}'], + environment: calls[0]?.environment, + cwd: root, + }); +}); + +test('reports a missing machine mount before invoking external prerequisites', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-eval-install-mount-')); + t.after(() => rm(root, { recursive: true, force: true })); + const trials = join(root, 'trials'); + await mkdir(trials); + const restore = setMachinePaths({ + MAKA_TEST_PREFLIGHT_PYTHON: process.execPath, + MAKA_TEST_PREFLIGHT_TRIALS: trials, + MAKA_TEST_PREFLIGHT_MOUNT: join(root, 'missing-toolchain'), + }); + t.after(restore); + let commands = 0; + + await assert.rejects( + preflight(experiment(), join(root, 'spec.json'), { + runCommand: async () => { + commands += 1; + }, + }), + /machine path MAKA_TEST_PREFLIGHT_MOUNT does not exist/, + ); + assert.equal(commands, 0); +}); + +test('rejects an unusable trials root before invoking external prerequisites', { + skip: process.platform === 'win32' || process.geteuid?.() === 0, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-eval-install-trials-')); + t.after(() => rm(root, { recursive: true, force: true })); + const trials = join(root, 'trials'); + const mount = join(root, 'mount'); + await Promise.all([mkdir(trials), mkdir(mount)]); + await chmod(trials, 0o500); + const restore = setMachinePaths({ + MAKA_TEST_PREFLIGHT_PYTHON: process.execPath, + MAKA_TEST_PREFLIGHT_TRIALS: trials, + MAKA_TEST_PREFLIGHT_MOUNT: mount, + }); + t.after(restore); + let commands = 0; + + await assert.rejects( + preflight(experiment(), join(root, 'spec.json'), { + runCommand: async () => { + commands += 1; + }, + }), + /machine path MAKA_TEST_PREFLIGHT_TRIALS is not writable and searchable/, + ); + assert.equal(commands, 0); +}); + +test('rejects a dangling trials root symlink before invoking external prerequisites', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-eval-install-trials-link-')); + t.after(() => rm(root, { recursive: true, force: true })); + const trials = join(root, 'trials'); + const mount = join(root, 'mount'); + await mkdir(mount); + try { + await symlink(join(root, 'missing-trials'), trials, 'dir'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('symlinks require additional privileges on this platform'); + return; + } + throw error; + } + const restore = setMachinePaths({ + MAKA_TEST_PREFLIGHT_PYTHON: process.execPath, + MAKA_TEST_PREFLIGHT_TRIALS: trials, + MAKA_TEST_PREFLIGHT_MOUNT: mount, + }); + t.after(restore); + let commands = 0; + + await assert.rejects( + preflight(experiment(), join(root, 'spec.json'), { + runCommand: async () => { + commands += 1; + }, + }), + /machine path MAKA_TEST_PREFLIGHT_TRIALS is a dangling symbolic link/, + ); + assert.equal(commands, 0); +}); + +test('identifies a mismatched Python framework environment', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-eval-install-python-')); + t.after(() => rm(root, { recursive: true, force: true })); + const trials = join(root, 'trials'); + const mount = join(root, 'mount'); + await Promise.all([mkdir(trials), mkdir(mount)]); + const restore = setMachinePaths({ + MAKA_TEST_PREFLIGHT_PYTHON: process.execPath, + MAKA_TEST_PREFLIGHT_TRIALS: trials, + MAKA_TEST_PREFLIGHT_MOUNT: mount, + }); + t.after(restore); + + await assert.rejects( + preflight(experiment(), join(root, 'spec.json'), { + runCommand: async () => { + throw new Error('installed 0.19.0, expected 0.20.0'); + }, + }), + /harbor Python environment MAKA_TEST_PREFLIGHT_PYTHON .* harbor@0\.20\.0: installed 0\.19\.0/, + ); +}); + +test('rejects egress assets that escape their declared source root', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-eval-install-egress-')); + t.after(() => rm(root, { recursive: true, force: true })); + const trials = join(root, 'trials'); + const mount = join(root, 'mount'); + const egress = join(root, 'egress'); + await Promise.all([mkdir(trials), mkdir(mount), mkdir(egress)]); + const restore = setMachinePaths({ + MAKA_TEST_PREFLIGHT_PYTHON: process.execPath, + MAKA_TEST_PREFLIGHT_TRIALS: trials, + MAKA_TEST_PREFLIGHT_MOUNT: mount, + MAKA_TEST_PREFLIGHT_EGRESS: egress, + }); + t.after(restore); + let commands = 0; + + await assert.rejects( + preflight(experiment(undefined, true, 'nested/../../compose.yaml'), join(root, 'spec.json'), { + runCommand: async () => { + commands += 1; + }, + }), + /egress proxy compose path escapes its source root/, + ); + assert.equal(commands, 0); +}); + +test('rejects egress assets that escape through a symlink', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-eval-install-egress-link-')); + t.after(() => rm(root, { recursive: true, force: true })); + const trials = join(root, 'trials'); + const mount = join(root, 'mount'); + const egress = join(root, 'egress'); + await Promise.all([mkdir(trials), mkdir(mount), mkdir(egress)]); + await Promise.all([ + writeFile(join(root, 'outside-compose.yaml'), 'services: {}\n'), + writeFile(join(egress, 'network-policy.json'), '{}\n'), + ]); + try { + await symlink('../outside-compose.yaml', join(egress, 'compose-link.yaml')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('symlinks require additional privileges on this platform'); + return; + } + throw error; + } + const restore = setMachinePaths({ + MAKA_TEST_PREFLIGHT_PYTHON: process.execPath, + MAKA_TEST_PREFLIGHT_TRIALS: trials, + MAKA_TEST_PREFLIGHT_MOUNT: mount, + MAKA_TEST_PREFLIGHT_EGRESS: egress, + }); + t.after(restore); + + await assert.rejects( + preflight(experiment(undefined, true, 'compose-link.yaml'), join(root, 'spec.json'), { + runCommand: async () => assert.fail('symlink escape must fail before external probes'), + }), + /egress proxy compose path escapes its source root/, + ); +}); + +test('propagates cancellation to an active prerequisite probe', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-eval-install-cancel-')); + t.after(() => rm(root, { recursive: true, force: true })); + const trials = join(root, 'trials'); + const mount = join(root, 'mount'); + await Promise.all([mkdir(trials), mkdir(mount)]); + const restore = setMachinePaths({ + MAKA_TEST_PREFLIGHT_PYTHON: process.execPath, + MAKA_TEST_PREFLIGHT_TRIALS: trials, + MAKA_TEST_PREFLIGHT_MOUNT: mount, + }); + t.after(restore); + const controller = new AbortController(); + const reason = new Error('preflight cancelled'); + let commands = 0; + + await assert.rejects( + preflight( + experiment(), + join(root, 'spec.json'), + { + runCommand: async (_command, _args, _environment, _cwd, signal) => { + commands += 1; + assert.equal(signal, controller.signal); + controller.abort(reason); + signal?.throwIfAborted(); + }, + }, + controller.signal, + ), + reason, + ); + assert.equal(commands, 1); +}); + +function experiment(credential?: string, egress = false, composeRelativePath = 'compose.yaml') { + return parseExperimentSpec({ + schemaVersion: 'maka.eval.v1', + id: 'preflight', + benchmark: { id: 'benchmark', version: '1', config: {} }, + executor: { + kind: 'harbor', + config: { + frameworkVersion: '0.20.0', + pythonPathEnv: 'MAKA_TEST_PREFLIGHT_PYTHON', + trialsRootEnv: 'MAKA_TEST_PREFLIGHT_TRIALS', + environment: { type: 'docker' }, + preparationEnvironment: credential ? [credential] : [], + mounts: [ + { + sourceEnv: 'MAKA_TEST_PREFLIGHT_MOUNT', + target: '/opt/toolchain', + readOnly: true, + }, + ], + ...(egress + ? { + egressProxy: { + composeSourceEnv: 'MAKA_TEST_PREFLIGHT_EGRESS', + composeRelativePath, + networkPolicyRelativePath: 'network-policy.json', + proxyUrl: 'http://maka-eval-mitmproxy:8080', + allowedHost: 'api.example.test', + containerCaPath: '/opt/maka/ca.pem', + }, + } + : {}), + }, + }, + subjects: [ + { + id: 'subject', + kind: 'external', + credentials: credential ? [credential] : [], + config: {}, + }, + ], + tasks: [{ id: 'task', input: 'do work', config: {} }], + repetitions: 1, + budget: {}, + verifier: {}, + }); +} + +function preflight( + spec: ExperimentSpec, + specPath: string, + dependencies: Partial, + signal?: AbortSignal, +): Promise { + if (spec.executor.kind !== 'harbor') throw new Error('test requires Harbor'); + const executor = createHarborExecutor(spec.executor.config, specPath); + return executor.preflight( + { + subjectCredentialNames: spec.subjects.flatMap((subject) => subject.credentials), + ...(signal ? { signal } : {}), + }, + dependencies, + ); +} + +function setMachinePaths(values: Readonly>): () => void { + const previous = Object.fromEntries( + Object.keys(values).map((name) => [name, process.env[name]]), + ) as Record; + Object.assign(process.env, values); + return () => { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }; +} diff --git a/packages/eval/src/cli.ts b/packages/eval/src/cli.ts index 09fc4957db..f92e0406b3 100644 --- a/packages/eval/src/cli.ts +++ b/packages/eval/src/cli.ts @@ -1,7 +1,11 @@ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { createExternalSubjectAdapter } from './external-subject.js'; -import { createHarborExecutor, createPierExecutor } from './harness-executor.js'; +import { + createHarborExecutor, + createPierExecutor, + type HarnessExecutor, +} from './harness-executor.js'; import { openExperimentDirectory } from './experiment-directory.js'; import type { ExperimentSpec } from './experiment.js'; import { createMakaSubjectAdapter } from './maka-subject.js'; @@ -46,11 +50,23 @@ export async function runMakaEvalCli( const specPath = resolve(command.specPath); const spec = parseExperimentSpec(JSON.parse(await readFile(specPath, 'utf8')) as unknown); const directory = await openExperimentDirectory(resolve(command.outDir), spec); - const loadExecutor = overrides.loadExecutor ?? builtinExecutor; + let executor: ExperimentExecutor; + if (overrides.loadExecutor) { + executor = overrides.loadExecutor(spec, specPath); + } else { + const builtin = builtinExecutor(spec, specPath); + await builtin.preflight({ + subjectCredentialNames: [ + ...new Set(spec.subjects.flatMap((subject) => subject.credentials)), + ], + ...(signal ? { signal } : {}), + }); + executor = builtin; + } const results = await runExperiment({ spec, store: directory.attempts, - executor: loadExecutor(spec, specPath), + executor, subjects: overrides.subjects ?? [createMakaSubjectAdapter(), createExternalSubjectAdapter()], ...(command.cellIds.length > 0 ? { cellIds: command.cellIds } : {}), ...(signal ? { signal } : {}), @@ -73,7 +89,7 @@ export async function runMakaEvalCli( } } -function builtinExecutor(spec: ExperimentSpec, specPath: string): ExperimentExecutor { +function builtinExecutor(spec: ExperimentSpec, specPath: string): HarnessExecutor { if (spec.executor.kind === 'harbor') return createHarborExecutor(spec.executor.config, specPath); if (spec.executor.kind === 'pier') return createPierExecutor(spec.executor.config, specPath); throw new Error(`unsupported executor: ${spec.executor.kind}`); diff --git a/packages/eval/src/harness-environment.ts b/packages/eval/src/harness-environment.ts new file mode 100644 index 0000000000..e2a91c020e --- /dev/null +++ b/packages/eval/src/harness-environment.ts @@ -0,0 +1,86 @@ +import { realpath } from 'node:fs/promises'; +import { delimiter, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const BUNDLED_HARNESS_RELAY_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + '../harbor', +); + +interface HarnessPreparationEnvironmentOptions { + readonly subjectCredentialNames: readonly string[]; + readonly declared: readonly string[]; + readonly egress?: { + readonly allowedHost: string; + readonly networkPolicyPath: string; + }; +} + +export function createHarnessPreparationEnvironment( + options: HarnessPreparationEnvironmentOptions, +): NodeJS.ProcessEnv { + const allowed = new Set([ + 'HOME', + 'PATH', + 'TMPDIR', + 'TMP', + 'TEMP', + 'LANG', + 'LC_ALL', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'REQUESTS_CA_BUNDLE', + 'CURL_CA_BUNDLE', + 'XDG_CACHE_HOME', + ...options.declared, + ]); + const credentials = new Set(options.subjectCredentialNames); + const inherited = Object.fromEntries( + [...allowed].flatMap((name) => { + const value = process.env[name]; + return value === undefined || credentials.has(name) ? [] : [[name, value]]; + }), + ); + return { + ...inherited, + PYTHONPATH: [BUNDLED_HARNESS_RELAY_ROOT, inherited.PYTHONPATH].filter(Boolean).join(delimiter), + ...(options.egress + ? { + MAKA_EVAL_EGRESS_REQUIRED: '1', + MAKA_EVAL_EGRESS_ALLOWED_HOST: options.egress.allowedHost, + MAKA_EVAL_NETWORK_POLICY_PATH: options.egress.networkPolicyPath, + } + : {}), + }; +} + +export function resolvePathWithinRoot(root: string, path: string, label: string): string { + const resolved = resolve(root, path); + assertPathWithinRoot(root, resolved, label); + return resolved; +} + +export async function resolveRealPathWithinRoot( + root: string, + path: string, + label: string, +): Promise { + const resolved = resolvePathWithinRoot(root, path, label); + let canonicalRoot: string; + let canonicalPath: string; + try { + [canonicalRoot, canonicalPath] = await Promise.all([realpath(root), realpath(resolved)]); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`${label} cannot be resolved: ${reason}`, { cause: error }); + } + assertPathWithinRoot(canonicalRoot, canonicalPath, label); + return canonicalPath; +} + +function assertPathWithinRoot(root: string, path: string, label: string): void { + const fromRoot = relative(root, path); + if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new Error(`${label} escapes its source root`); + } +} diff --git a/packages/eval/src/harness-executor.ts b/packages/eval/src/harness-executor.ts index 8d79f91088..dbdee59f66 100644 --- a/packages/eval/src/harness-executor.ts +++ b/packages/eval/src/harness-executor.ts @@ -4,10 +4,18 @@ import { once } from 'node:events'; import { createReadStream } from 'node:fs'; import { chmod, lstat, mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises'; import { createServer, type Server, type Socket } from 'node:net'; -import { basename, delimiter, dirname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import { createInterface } from 'node:readline'; -import { fileURLToPath } from 'node:url'; import { decodeJsonObject, type ExperimentCell, type JsonObject } from './experiment.js'; +import { + BUNDLED_HARNESS_RELAY_ROOT, + createHarnessPreparationEnvironment, + resolveRealPathWithinRoot, +} from './harness-environment.js'; +import { + preflightHarnessInstallation, + type HarnessPreflightDependencies, +} from './install-preflight.js'; import { MAKA_RUNTIME_ARTIFACT_PATH, MAKA_SUBJECT_STDERR_PATH, @@ -22,7 +30,7 @@ import { } from './runner.js'; import type { EvalResult } from './result.js'; -type Framework = 'harbor' | 'pier'; +export type HarnessFramework = 'harbor' | 'pier'; type RelayTransportStage = 'ready' | 'execute' | 'receive' | 'decision'; interface RelayTransportFailure { @@ -62,22 +70,43 @@ type SubjectProcessDiagnostic = NonNullable< Awaited>['diagnostic'] >; -export function createHarborExecutor(config: JsonObject, specPath: string): ExperimentExecutor { +export interface HarnessExecutor extends ExperimentExecutor { + preflight( + input: { + readonly subjectCredentialNames: readonly string[]; + readonly signal?: AbortSignal; + }, + dependencies?: Partial, + ): Promise; +} + +export function createHarborExecutor(config: JsonObject, specPath: string): HarnessExecutor { return createHarnessExecutor('harbor', config, specPath); } -export function createPierExecutor(config: JsonObject, specPath: string): ExperimentExecutor { +export function createPierExecutor(config: JsonObject, specPath: string): HarnessExecutor { return createHarnessExecutor('pier', config, specPath); } function createHarnessExecutor( - framework: Framework, + framework: HarnessFramework, config: JsonObject, specPath: string, -): ExperimentExecutor { - const options = decodeOptions(config, framework); - const executor: ExperimentExecutor = { +): HarnessExecutor { + const options = decodeHarnessOptions(config, framework); + const executor: HarnessExecutor = { kind: framework, + preflight: (input, dependencies) => + preflightHarnessInstallation( + { + framework, + options, + specPath, + subjectCredentialNames: input.subjectCredentialNames, + ...(input.signal ? { signal: input.signal } : {}), + }, + dependencies, + ), validate: (cell) => { decodeTask(framework, options, cell); }, @@ -88,7 +117,7 @@ function createHarnessExecutor( } async function runHarnessAttempt( - framework: Framework, + framework: HarnessFramework, options: HarnessOptions, specPath: string, { @@ -331,7 +360,7 @@ function validProcessDiagnostic(value: unknown): value is { } async function startTrial( - framework: Framework, + framework: HarnessFramework, options: HarnessOptions, specPath: string, cell: ExperimentCell, @@ -351,20 +380,25 @@ async function startTrial( const trialPath = join(trialsRoot, trialName); const task = decodeTask(framework, options, cell); const timeoutMultiplier = positive(cell.budget.timeoutMultiplier, 'budget.timeoutMultiplier'); - const environmentConfig = resolveEnvironmentConfig(options); - const networkPolicyPath = resolveNetworkPolicyPath(options); - const relayPath = resolve(dirname(fileURLToPath(import.meta.url)), '../harbor'); + const egressPaths = await resolveEgressPaths(options); + const environmentConfig = resolveEnvironmentConfig(options, egressPaths); + const networkPolicyPath = egressPaths?.networkPolicyPath; const executionEnvironment = { ...UNATTENDED_EXECUTION_ENVIRONMENT, ...egressExecutionEnvironment(options.egressProxy), }; - const environment = preparationEnvironment( - relayPath, - [...subjectCredentialNames, ...cell.subject.credentials], - options.preparationEnvironment, - options.egressProxy?.allowedHost, - networkPolicyPath, - ); + const environment = createHarnessPreparationEnvironment({ + subjectCredentialNames: [...subjectCredentialNames, ...cell.subject.credentials], + declared: options.preparationEnvironment, + ...(options.egressProxy && networkPolicyPath + ? { + egress: { + allowedHost: options.egressProxy.allowedHost, + networkPolicyPath, + }, + } + : {}), + }); const server = createServer(); const connections = new Set(); server.on('connection', (socket) => { @@ -420,7 +454,12 @@ async function startTrial( ); child = spawn( process.env[options.pythonPathEnv]!, - [join(relayPath, 'run_trial.py'), framework, options.frameworkVersion, configPath], + [ + join(BUNDLED_HARNESS_RELAY_ROOT, 'run_trial.py'), + framework, + options.frameworkVersion, + configPath, + ], { cwd: dirname(specPath), env: environment, stdio: 'ignore' }, ); await once(child, 'spawn', signal ? { signal } : undefined); @@ -589,48 +628,6 @@ function preparationCode( return 'exit-before-ready'; } -function preparationEnvironment( - relayPath: string, - subjectCredentialNames: readonly string[], - declared: readonly string[], - egressAllowedHost?: string, - networkPolicyPath?: string, -): NodeJS.ProcessEnv { - const allowed = new Set([ - 'HOME', - 'PATH', - 'TMPDIR', - 'TMP', - 'TEMP', - 'LANG', - 'LC_ALL', - 'SSL_CERT_FILE', - 'SSL_CERT_DIR', - 'REQUESTS_CA_BUNDLE', - 'CURL_CA_BUNDLE', - 'XDG_CACHE_HOME', - ...declared, - ]); - const credentials = new Set(subjectCredentialNames); - const inherited = Object.fromEntries( - [...allowed].flatMap((name) => { - const value = process.env[name]; - return value === undefined || credentials.has(name) ? [] : [[name, value]]; - }), - ); - return { - ...inherited, - PYTHONPATH: [relayPath, inherited.PYTHONPATH].filter(Boolean).join(delimiter), - ...(egressAllowedHost - ? { - MAKA_EVAL_EGRESS_REQUIRED: '1', - MAKA_EVAL_EGRESS_ALLOWED_HOST: egressAllowedHost, - } - : {}), - ...(networkPolicyPath ? { MAKA_EVAL_NETWORK_POLICY_PATH: networkPolicyPath } : {}), - }; -} - function mergeExecutionEnvironment( required: Readonly>, subject: Readonly>, @@ -847,7 +844,7 @@ async function walkCollectedArtifacts( } } -interface HarnessOptions { +export interface HarnessOptions { readonly frameworkVersion: string; readonly pythonPathEnv: string; readonly trialsRootEnv: string; @@ -869,7 +866,7 @@ interface HarnessOptions { }[]; } -function decodeOptions(value: JsonObject, framework: Framework): HarnessOptions { +function decodeHarnessOptions(value: JsonObject, framework: HarnessFramework): HarnessOptions { if (!Object.hasOwn(value, 'preparationEnvironment')) { throw new Error('executor.config.preparationEnvironment is required'); } @@ -975,28 +972,42 @@ function resolveMounts(mounts: HarnessOptions['mounts']) { })); } -function resolveEnvironmentConfig(options: HarnessOptions): JsonObject { +interface ResolvedEgressPaths { + readonly composePath: string; + readonly networkPolicyPath: string; +} + +function resolveEnvironmentConfig( + options: HarnessOptions, + egressPaths: ResolvedEgressPaths | undefined, +): JsonObject { const base = { ...options.environment, mounts: resolveMounts(options.mounts) }; if (!options.egressProxy) return base; - const source = resolve(process.env[options.egressProxy.composeSourceEnv]!); - const composePath = resolve(source, options.egressProxy.composeRelativePath); - if (relative(source, composePath).startsWith(`..${sep}`)) { - throw new Error('egress proxy compose path escapes its source root'); - } - return { ...base, extra_docker_compose: [composePath] }; + if (!egressPaths) throw new Error('egress proxy paths are unavailable'); + return { ...base, extra_docker_compose: [egressPaths.composePath] }; } -function resolveNetworkPolicyPath(options: HarnessOptions): string | undefined { +async function resolveEgressPaths( + options: HarnessOptions, +): Promise { if (!options.egressProxy) return undefined; const source = resolve(process.env[options.egressProxy.composeSourceEnv]!); - const policyPath = resolve(source, options.egressProxy.networkPolicyRelativePath); - if (relative(source, policyPath).startsWith(`..${sep}`)) { - throw new Error('egress network policy path escapes its source root'); - } - return policyPath; + const [composePath, networkPolicyPath] = await Promise.all([ + resolveRealPathWithinRoot( + source, + options.egressProxy.composeRelativePath, + 'egress proxy compose path', + ), + resolveRealPathWithinRoot( + source, + options.egressProxy.networkPolicyRelativePath, + 'egress network policy path', + ), + ]); + return { composePath, networkPolicyPath }; } -function decodeTask(framework: Framework, options: HarnessOptions, cell: ExperimentCell) { +function decodeTask(framework: HarnessFramework, options: HarnessOptions, cell: ExperimentCell) { if (framework === 'harbor') { const benchmark = exact(cell.benchmark.config, ['repository'], 'benchmark.config'); const task = exact(cell.task.config, ['harbor'], 'task.config'); diff --git a/packages/eval/src/install-preflight.ts b/packages/eval/src/install-preflight.ts new file mode 100644 index 0000000000..b56e58dd50 --- /dev/null +++ b/packages/eval/src/install-preflight.ts @@ -0,0 +1,267 @@ +import { execFile } from 'node:child_process'; +import { constants } from 'node:fs'; +import { access, lstat, stat } from 'node:fs/promises'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { + BUNDLED_HARNESS_RELAY_ROOT, + createHarnessPreparationEnvironment, + resolvePathWithinRoot, + resolveRealPathWithinRoot, +} from './harness-environment.js'; +import type { HarnessFramework, HarnessOptions } from './harness-executor.js'; + +const PREFLIGHT_TIMEOUT_MS = 10_000; +const PREFLIGHT_OUTPUT_LIMIT_BYTES = 16 * 1024; +const PYTHON_FRAMEWORK_PROBE = [ + 'from importlib import import_module', + 'from importlib.metadata import version', + 'import sys', + 'actual = version(sys.argv[1])', + 'if actual != sys.argv[2]:', + ' raise SystemExit(f"installed {actual}, expected {sys.argv[2]}")', + 'import_module(f"{sys.argv[3]}.models.trial.config")', +].join('\n'); + +export interface HarnessPreflightDependencies { + readonly runCommand: ( + command: string, + args: readonly string[], + environment: NodeJS.ProcessEnv, + cwd: string, + signal?: AbortSignal, + ) => Promise; +} + +interface HarnessPreflightInput { + readonly framework: HarnessFramework; + readonly options: HarnessOptions; + readonly specPath: string; + readonly subjectCredentialNames: readonly string[]; + readonly signal?: AbortSignal; +} + +export async function preflightHarnessInstallation( + input: HarnessPreflightInput, + dependencies: Partial = {}, +): Promise { + input.signal?.throwIfAborted(); + const { framework, options, specPath } = input; + const pythonPath = pythonCommand(options.pythonPathEnv, specPath); + const trialsRoot = machinePath(options.trialsRootEnv); + + if (isAbsolute(pythonPath)) { + await requirePath(pythonPath, `machine path ${options.pythonPathEnv}`, 'file'); + await access(pythonPath, constants.X_OK).catch(() => { + throw new Error(`machine path ${options.pythonPathEnv} is not executable: ${pythonPath}`); + }); + } + await requireUsableDirectory(trialsRoot, `machine path ${options.trialsRootEnv}`); + + if (options.tasksRootEnv) { + await requirePath( + machinePath(options.tasksRootEnv), + `machine path ${options.tasksRootEnv}`, + 'directory', + ); + } + + for (const mount of options.mounts) { + await requirePath(machinePath(mount.sourceEnv), `machine path ${mount.sourceEnv}`, 'any'); + } + + let networkPolicyPath: string | undefined; + if (options.egressProxy) { + const source = machinePath(options.egressProxy.composeSourceEnv); + await requirePath(source, `machine path ${options.egressProxy.composeSourceEnv}`, 'directory'); + const composeCandidate = resolvePathWithinRoot( + source, + options.egressProxy.composeRelativePath, + 'egress proxy compose path', + ); + const networkPolicyCandidate = resolvePathWithinRoot( + source, + options.egressProxy.networkPolicyRelativePath, + 'egress network policy path', + ); + await requirePath(composeCandidate, 'Eval egress Compose overlay', 'file'); + await requirePath(networkPolicyCandidate, 'Eval egress network policy', 'file'); + [, networkPolicyPath] = await Promise.all([ + resolveRealPathWithinRoot( + source, + options.egressProxy.composeRelativePath, + 'egress proxy compose path', + ), + resolveRealPathWithinRoot( + source, + options.egressProxy.networkPolicyRelativePath, + 'egress network policy path', + ), + ]); + } + + for (const asset of ['eval_framework.py', 'relay_agent.py', 'run_trial.py']) { + await requirePath( + resolve(BUNDLED_HARNESS_RELAY_ROOT, asset), + `bundled Eval runtime ${asset}`, + 'file', + ); + } + + input.signal?.throwIfAborted(); + const runCommand = dependencies.runCommand ?? runCheckedCommand; + const environment = createHarnessPreparationEnvironment({ + subjectCredentialNames: input.subjectCredentialNames, + declared: options.preparationEnvironment, + ...(options.egressProxy && networkPolicyPath + ? { + egress: { + allowedHost: options.egressProxy.allowedHost, + networkPolicyPath, + }, + } + : {}), + }); + const workingDirectory = dirname(specPath); + const distribution = framework === 'harbor' ? 'harbor' : 'datacurve-pier'; + try { + await runCommand( + pythonPath, + ['-c', PYTHON_FRAMEWORK_PROBE, distribution, options.frameworkVersion, framework], + environment, + workingDirectory, + input.signal, + ); + } catch (error) { + if (input.signal?.aborted) input.signal.throwIfAborted(); + throw new Error( + `${framework} Python environment ${options.pythonPathEnv} is unavailable or does not provide ${distribution}@${options.frameworkVersion}: ${errorMessage(error)}`, + ); + } + + input.signal?.throwIfAborted(); + if (options.environment.type === 'docker') { + try { + await runCommand( + 'docker', + ['version', '--format', '{{.Server.Version}}'], + environment, + workingDirectory, + input.signal, + ); + } catch (error) { + if (input.signal?.aborted) input.signal.throwIfAborted(); + throw new Error(`Docker CLI or daemon is unavailable: ${errorMessage(error)}`); + } + } +} + +async function runCheckedCommand( + command: string, + args: readonly string[], + environment: NodeJS.ProcessEnv, + cwd: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + await new Promise((resolvePromise, rejectPromise) => { + execFile( + command, + [...args], + { + env: environment, + cwd, + timeout: PREFLIGHT_TIMEOUT_MS, + killSignal: 'SIGKILL', + maxBuffer: PREFLIGHT_OUTPUT_LIMIT_BYTES, + encoding: 'utf8', + ...(signal ? { signal } : {}), + }, + (error, _stdout, stderr) => { + if (!error) { + resolvePromise(); + return; + } + rejectPromise(new Error(stderr.trim() || error.message)); + }, + ); + }); +} + +async function requirePath( + path: string, + label: string, + expected: 'any' | 'file' | 'directory', +): Promise { + let metadata; + try { + metadata = await stat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`${label} does not exist: ${path}`); + } + throw new Error(`${label} is inaccessible: ${path}: ${errorMessage(error)}`); + } + if (expected === 'file' && !metadata.isFile()) throw new Error(`${label} is not a file: ${path}`); + if (expected === 'directory' && !metadata.isDirectory()) { + throw new Error(`${label} is not a directory: ${path}`); + } +} + +async function requireUsableDirectory(path: string, label: string): Promise { + let candidate = path; + while (true) { + let metadata; + try { + metadata = await stat(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`${label} is inaccessible: ${candidate}: ${errorMessage(error)}`); + } + let linkMetadata; + try { + linkMetadata = await lstat(candidate); + } catch (linkError) { + if ((linkError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`${label} is inaccessible: ${candidate}: ${errorMessage(linkError)}`); + } + } + if (linkMetadata?.isSymbolicLink()) { + throw new Error(`${label} is a dangling symbolic link: ${candidate}`); + } + if (linkMetadata) continue; + const parent = dirname(candidate); + if (parent === candidate) { + throw new Error(`${label} has no accessible parent directory: ${path}`); + } + candidate = parent; + continue; + } + + if (!metadata.isDirectory()) { + const subject = candidate === path ? label : `${label} parent`; + throw new Error(`${subject} is not a directory: ${candidate}`); + } + await access(candidate, constants.W_OK | constants.X_OK).catch(() => { + const subject = candidate === path ? label : `${label} nearest existing parent`; + throw new Error(`${subject} is not writable and searchable: ${candidate}`); + }); + return; + } +} + +function machinePath(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`machine path ${name} is unavailable`); + return resolve(value); +} + +function pythonCommand(name: string, specPath: string): string { + const value = process.env[name]; + if (!value) throw new Error(`machine path ${name} is unavailable`); + if (isAbsolute(value)) return value; + return value.includes('/') || value.includes('\\') ? resolve(dirname(specPath), value) : value; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +}