diff --git a/containers/enclave/mcp-server/config.js b/containers/enclave/mcp-server/config.js index 32cb8bf69..c4881cb8a 100644 --- a/containers/enclave/mcp-server/config.js +++ b/containers/enclave/mcp-server/config.js @@ -384,6 +384,7 @@ function loadHostConfig(kind, server) { entryId, runId, executorBackend: 'cloud-hypervisor', + executorTransport: 'host', backend: 'cloud-hypervisor', primaryBackend: server.primaryBackend, hostExecutorSocketPath: path.join(HOST_EXECUTOR_DIR, 'executor.sock'), diff --git a/containers/enclave/mcp-server/host-executor-runner.js b/containers/enclave/mcp-server/host-executor-runner.js index 82c379edc..959267781 100644 --- a/containers/enclave/mcp-server/host-executor-runner.js +++ b/containers/enclave/mcp-server/host-executor-runner.js @@ -31,8 +31,12 @@ const FAILURE_REASONS = new Map([ * Internal broker adapter. AWF constructs the listener only after trusted * prerequisite checks. Repository staging and result files belong to AWF. */ +function usesHostExecutor(config) { + return config.executorTransport === 'host' || config.executorBackend === 'cloud-hypervisor'; +} + function createHostExecutorRunner(config, deps = {}) { - if (config.executorBackend !== 'cloud-hypervisor' + if (!usesHostExecutor(config) || !/^[a-z0-9](?:[a-z0-9-]{0,62})$/.test(config.entryId || '')) { throw new Error('Invalid trusted host executor configuration'); } @@ -222,4 +226,4 @@ function createHostExecutorRunner(config, deps = {}) { }; } -module.exports = { createHostExecutorRunner }; +module.exports = { createHostExecutorRunner, usesHostExecutor }; diff --git a/containers/enclave/mcp-server/server.js b/containers/enclave/mcp-server/server.js index 6f5d081b8..a032961f3 100644 --- a/containers/enclave/mcp-server/server.js +++ b/containers/enclave/mcp-server/server.js @@ -26,7 +26,7 @@ const { const { AGENT_TOOL_NAME, TOOL_NAME, dispatchJsonRpc, parseJsonRpcBody } = require('./mcp-protocol'); const { createDynamicDelegationClient } = require('./delegation-channel'); const { createToolCallBudget } = require('./tool-call-budget'); -const { createHostExecutorRunner } = require('./host-executor-runner'); +const { createHostExecutorRunner, usesHostExecutor } = require('./host-executor-runner'); const { createStartupReporter, runStartupStage, startupDiagnostic } = require('./startup-diagnostics'); const { probeStartupEnvironment } = require('./environment-probes'); @@ -271,7 +271,7 @@ async function main() { ); const runner = await runStartupStage( startupReporter, 'script-selection', 'script-runner-initialization-failed', () => - config.executorBackend === 'cloud-hypervisor' + usesHostExecutor(config) ? createHostExecutorRunner(config, { nowMs: clock.nowMs, audit: hostExecutorAudit }) : createScriptRunner(config, { nowMs: clock.nowMs }), { executorKind: 'script' }, @@ -317,7 +317,7 @@ async function main() { ); const runner = await runStartupStage( startupReporter, 'agent-selection', 'agent-runner-initialization-failed', () => - config.executorBackend === 'cloud-hypervisor' + usesHostExecutor(config) ? createHostExecutorRunner(config, { nowMs: clock.nowMs, audit: hostExecutorAudit }) : createAgentRunner(config, { nowMs: clock.nowMs }), { executorKind: 'agent' }, diff --git a/docs/enclaves-architecture.md b/docs/enclaves-architecture.md index 719808956..e87cceb2c 100644 --- a/docs/enclaves-architecture.md +++ b/docs/enclaves-architecture.md @@ -846,6 +846,44 @@ See [ADR 0001: Agent enclave repository admission](adr/0001-agent-enclaves.md) for the stable compiler, mcpg, runtime-registry, executor, and integration contract. +## Shared host execution core + +The host-executor protocol and server own authenticated invocation planning, +cancellation, leases, cleanup-before-terminal results, and digest-bound settlement. +The shared modules under `src/enclave/` additionally provide: + +- `execution-policy.ts`, `invocation-staging.ts`, and `invocation-result.ts`: + trusted role limits and identities, private input staging, and bounded canonical + schema-valid results. +- `host-executor-run-state.ts`: static seed authority derived from the validated + host catalog and staged run identity, never from broker launch settings. +- `host-service.ts` and `host-executor-lifecycle.ts`: preflight-before-listening, + configuration ownership, readiness, admission closure, startup/shutdown races, + draining, recovery verification ordering, and protected evidence collection. +- `bounded-storage.ts`: aggregate storage requirements and verification hooks. + Adapters must enforce capacity across all writable state, including snapshots, + runtime state, sparse files, and concurrent writers, until cleanup succeeds. + A nominal image size, free-space check, or per-file limit is not enforcement. + +Backend adapters retain artifact discovery/provenance, launch and confinement, +mount realization, networking, resource identity checks, journal formats, and +reaping. Failed or uncertain cleanup retains lifecycle and recovery state; it +does not authorize storage release or a runtime fallback. Diagnostic collection +does not bypass existing visibility, redaction, or protected-storage checks. + +Host-service readiness does not imply a persistent workload instance. The +`HostEnclaveExecutorBackend.execute(plan, signal)` seam accommodates atomic +one-shot execution and container-owned lifecycles without `startInstance()`. +Primary runtime integration remains separate: Docker Compose and NVX are not +forced into the Cloud Hypervisor primary-manager lifecycle. + +The broker routes trusted `executorTransport: 'host'` configurations through the +same protocol, retaining Cloud Hypervisor compatibility. Transport and backend +are not invocation fields. This extraction does not enable NVX enclaves, +migrate Docker execution, change dynamic delegation, or relax any Cloud +Hypervisor host, artifact, storage, or real-backend acceptance gate. The reviewed +cross-backend capability contract in #10339 remains a prerequisite for migration. + ## Cloud Hypervisor enclave executor Cloud Hypervisor supports static enclave runtime selection only after trusted diff --git a/src/cloud-hypervisor/enclave-executor-types.ts b/src/cloud-hypervisor/enclave-executor-types.ts index 5bd949439..d28f0259d 100644 --- a/src/cloud-hypervisor/enclave-executor-types.ts +++ b/src/cloud-hypervisor/enclave-executor-types.ts @@ -4,7 +4,8 @@ import type { HostExecutorRunState, } from '../enclave/host-executor-server'; import type { HostExecutorResourceJournal } from '../enclave/host-executor-journal'; -import type { EnclaveAgentProfile } from '../types/enclave-options'; +import type { HostExecutorAgentPolicy } from '../enclave/execution-policy'; +export type { HostExecutorAgentPolicy } from '../enclave/execution-policy'; import type { CloudHypervisorOptions } from '../types/runtime-options'; import type { CloudHypervisorEnclaveArtifactManifest, @@ -44,17 +45,6 @@ export interface CloudHypervisorEnclaveArtifactPreflightOptions { readonly attestationToolPath: string; } -export interface HostExecutorAgentPolicy { - readonly model: string; - readonly profile: EnclaveAgentProfile; - readonly maxOutputBytes: number; - readonly maxModelRequests?: number; - readonly maxModelTokens?: number; - readonly modelBudgetSecret?: string; - readonly githubAgentId?: string; - readonly githubBearer?: string; -} - export interface HostEnclaveExecutorManager { start(): Promise; startInstance(): Promise; diff --git a/src/cloud-hypervisor/enclave-invocation-staging.ts b/src/cloud-hypervisor/enclave-invocation-staging.ts index 6e4a961dc..227b41b4d 100644 --- a/src/cloud-hypervisor/enclave-invocation-staging.ts +++ b/src/cloud-hypervisor/enclave-invocation-staging.ts @@ -10,6 +10,12 @@ import type { HostExecutorRunState, } from '../enclave/host-executor-server'; import { HOST_EXECUTOR_TIMEOUT_GRACE_MS } from '../enclave/host-executor-protocol'; +import { verifyBoundedInvocationStorage } from '../enclave/bounded-storage'; +import { + invocationFilePath as filePath, + stageInvocationInputs as stageSharedInvocationInputs, + writePrivateInvocationFile as writePrivateFile, +} from '../enclave/invocation-staging'; import { assertTrustedAncestorChain, calculateSha256, @@ -25,7 +31,6 @@ import type { CloudHypervisorHostToolPaths } from './preflight'; import { CLOUD_HYPERVISOR_ENCLAVE_RESOURCE_PROFILES } from './workload-profile'; import { createEnclaveModelBudgetToken } from '../enclave/model-budget-capability'; import { - assertNoHostGithubTokenValue, enforceGithubTokenIsolation, getGithubTokenEnvValues, } from '../services/agent-environment/github-token-isolation'; @@ -34,12 +39,6 @@ const GITHUB_PROFILE = 'issues-read-v1'; const GITHUB_ENDPOINT = `http://${ENCLAVE_AGENT_GITHUB_MCP_IP}:${ENCLAVE_GITHUB_MCP_PORT}/mcp/github`; const OUTPUT_NAME = 'out'; -function filePath(directory: string, name: string): string { - const result = path.join(directory, name); - if (path.dirname(result) !== directory) throw new Error('Invalid enclave artifact path'); - return result; -} - export async function prepareInvocationFilesystem( runState: HostExecutorRunState, plan: HostExecutorInvocationPlan, @@ -96,7 +95,9 @@ export async function prepareInvocationFilesystem( tools, ); await onMounted(); - await dependencies.verifyStorage(plan.invocationHostDir, resourceProfile.writableStorageBytes); + await verifyBoundedInvocationStorage(role, plan.invocationHostDir, + (directory, requirement) => + dependencies.verifyStorage(directory, requirement.maximumBytes)); for (const name of ['request', 'output', 'runtime']) { const directory = filePath(plan.invocationHostDir, name); await dependencies.mkdir(directory, { mode: 0o700 }); @@ -137,93 +138,14 @@ async function assertTrustedDirectory( } } -async function writePrivateFile( - filePathValue: string, - contents: string, - uid: number, - gid: number, - mode: number, - dependencies: Pick, -): Promise { - await dependencies.writeFile(filePathValue, contents, { encoding: 'utf8', mode, flag: 'wx' }); - await dependencies.chown(filePathValue, uid, gid); - await dependencies.chmod(filePathValue, mode); -} - export async function stageInvocationInputs( plan: HostExecutorInvocationPlan, role: CloudHypervisorEnclaveRole, policy: HostExecutorAgentPolicy | undefined, dependencies: Pick, ): Promise { - if (role === 'agent' && policy?.githubBearer) { - assertNoHostGithubTokenValue( - policy.githubBearer, - getGithubTokenEnvValues(), - 'Cloud Hypervisor enclave GitHub bearer file', - ); - } - const resourceProfile = role === 'script' - ? CLOUD_HYPERVISOR_ENCLAVE_RESOURCE_PROFILES.script - : CLOUD_HYPERVISOR_ENCLAVE_RESOURCE_PROFILES.agent; - const requestDir = filePath(plan.invocationHostDir, 'request'); - if (role === 'script') { - await writePrivateFile( - filePath(requestDir, 'query-script.py'), - plan.payload, - resourceProfile.uid, - resourceProfile.gid, - 0o400, - dependencies, - ); - return; - } - if (!policy) throw new Error('Trusted agent policy is required for an agent enclave'); - await writePrivateFile( - filePath(requestDir, 'task.txt'), - plan.payload, - resourceProfile.uid, - resourceProfile.gid, - 0o400, - dependencies, - ); - await writePrivateFile( - filePath(requestDir, 'schema.json'), - JSON.stringify(plan.schema), - resourceProfile.uid, - resourceProfile.gid, - 0o400, - dependencies, - ); - const runtimeDir = filePath(plan.invocationHostDir, 'runtime'); - const sessionFile = filePath(runtimeDir, 'session.jsonl'); - await writePrivateFile( - sessionFile, - '', - resourceProfile.uid, - resourceProfile.gid, - 0o600, - dependencies, - ); - if (policy.githubAgentId !== undefined && policy.githubBearer !== undefined) { - const handoffDir = filePath(plan.invocationHostDir, 'session-handoff'); - await writePrivateFile( - filePath(handoffDir, 'github-agent-id'), - `${policy.githubAgentId}\n`, - resourceProfile.uid, - resourceProfile.gid, - 0o400, - dependencies, - ); - await writePrivateFile( - filePath(handoffDir, 'github-bearer'), - `${policy.githubBearer}\n`, - resourceProfile.uid, - resourceProfile.gid, - 0o400, - dependencies, - ); - } + await stageSharedInvocationInputs(plan, role, policy, dependencies, + 'Cloud Hypervisor enclave GitHub bearer file'); } export function agentEnvironment( diff --git a/src/cloud-hypervisor/host-enclave-executor.ts b/src/cloud-hypervisor/host-enclave-executor.ts index e2ee2e57b..da33c5133 100644 --- a/src/cloud-hypervisor/host-enclave-executor.ts +++ b/src/cloud-hypervisor/host-enclave-executor.ts @@ -1,10 +1,8 @@ -import { constants, promises as fs } from 'fs'; -import { TextDecoder } from 'util'; +import { promises as fs } from 'fs'; import * as path from 'path'; -import { - canonicalizeSchemaValue, - validateValueAgainstSchema, -} from '../bounded-execution/finite-schema'; +import { normalizeAgentPolicy } from '../enclave/execution-policy'; +import { readBoundedEnclaveResult as readBoundedCloudHypervisorEnclaveResult } from '../enclave/invocation-result'; +export { readBoundedEnclaveResult as readBoundedCloudHypervisorEnclaveResult } from '../enclave/invocation-result'; import type { HostEnclaveExecutorBackend, HostExecutorInvocationPlan, @@ -88,19 +86,7 @@ export type { } from './enclave-executor-types'; export { preflightCloudHypervisorEnclaveArtifacts } from './enclave-artifact-preflight'; -const MAX_AGENT_MODEL_BYTES = 256; -const MAX_GITHUB_BEARER_BYTES = 512; const OUTPUT_NAME = 'out'; -const CREDENTIAL_PATTERN = /^[\x21-\x7e]{16,512}$/; -const AGENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; - -function hasControlCharacters(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - if (code < 0x20 || code === 0x7f) return true; - } - return false; -} const defaultDependencies: HostEnclaveExecutorDependencies = { createArtifactSnapshot: (sources, copy, onDirectoryCreated, report) => @@ -213,71 +199,6 @@ export async function createCloudHypervisorHostEnclaveExecutor( } } -function normalizeAgentPolicy( - policy: HostExecutorAgentPolicy | undefined, - timeoutMs: number, -): HostExecutorAgentPolicy | undefined { - if (!policy) return undefined; - if ( - typeof policy.model !== 'string' || - policy.model.length === 0 || - Buffer.byteLength(policy.model, 'utf8') > MAX_AGENT_MODEL_BYTES || - hasControlCharacters(policy.model) || - !['openai', 'anthropic'].includes(policy.profile) || - !Number.isSafeInteger(policy.maxOutputBytes) || - policy.maxOutputBytes < 1 || - policy.maxOutputBytes > HOST_EXECUTOR_MAX_RESULT_BYTES || - (policy.maxModelRequests !== undefined && - (!Number.isSafeInteger(policy.maxModelRequests) || policy.maxModelRequests < 1)) || - (policy.maxModelTokens !== undefined && - (!Number.isSafeInteger(policy.maxModelTokens) || policy.maxModelTokens < 1)) || - !Number.isSafeInteger(timeoutMs) || - timeoutMs < 1 - ) { - throw new Error('Trusted host executor agent policy is invalid'); - } - const hasGithubId = policy.githubAgentId !== undefined; - const hasGithubBearer = policy.githubBearer !== undefined; - if (hasGithubId !== hasGithubBearer) { - throw new Error('Trusted GitHub agent identity and bearer must be configured together'); - } - if ( - (policy.githubAgentId !== undefined && !AGENT_ID_PATTERN.test(policy.githubAgentId)) || - (policy.githubBearer !== undefined && - (!CREDENTIAL_PATTERN.test(policy.githubBearer) || - Buffer.byteLength(policy.githubBearer, 'ascii') > MAX_GITHUB_BEARER_BYTES)) - ) { - throw new Error('Trusted host executor GitHub credentials are invalid'); - } - return policy; -} - -export async function readBoundedCloudHypervisorEnclaveResult( - outputPath: string, - schema: HostExecutorInvocationPlan['schema'], - maxBytes: number, -): Promise { - let handle; - try { - handle = await fs.open(outputPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - const stat = await handle.stat(); - if (!stat.isFile() || stat.size < 1 || stat.size > maxBytes) return undefined; - const bytes = Buffer.alloc(maxBytes + 1); - const { bytesRead } = await handle.read(bytes, 0, maxBytes + 1, 0); - if (bytesRead > maxBytes) return undefined; - const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, bytesRead)); - const parsed: unknown = JSON.parse(text); - if (!validateValueAgainstSchema(schema, parsed)) return undefined; - const canonical = canonicalizeSchemaValue(schema, parsed); - if (Buffer.byteLength(canonical, 'utf8') > maxBytes) return undefined; - return canonical; - } catch { - return undefined; - } finally { - await handle?.close(); - } -} - function createWorkloadProfile( plan: HostExecutorInvocationPlan, exportPlan: CloudHypervisorEnclaveExportPlan, diff --git a/src/cloud-hypervisor/workload-profile.ts b/src/cloud-hypervisor/workload-profile.ts index 03c6eb55d..893a71faf 100644 --- a/src/cloud-hypervisor/workload-profile.ts +++ b/src/cloud-hypervisor/workload-profile.ts @@ -10,6 +10,12 @@ import { type EnclaveAgentProfile, } from '../types/enclave-options'; import type { MicrovmControlPeer } from '../microvm/network'; +import { + ENCLAVE_RESOURCE_POLICIES, + assertSafeWorkloadIdentity, + createEnclaveExecutionIdentity, + sameEnclaveResourcePolicy as sameResourceProfile, +} from '../enclave/execution-policy'; import type { CloudHypervisorManagerGuestConfig, CloudHypervisorEnclaveResourceProfile, @@ -113,38 +119,9 @@ export type CloudHypervisorLaunchableWorkloadProfile = | CloudHypervisorPrimaryAgentProfile | CloudHypervisorScriptEnclaveProfile; -const SAFE_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/; const SAFE_INTERFACE = /^[A-Za-z0-9_.-]{1,15}$/; -const MIB = 1024 * 1024; -export const CLOUD_HYPERVISOR_ENCLAVE_RESOURCE_PROFILES = Object.freeze({ - script: Object.freeze({ - role: 'script', - memoryMiB: 768, - vcpuCount: 1, - cpuQuotaMilli: 500, - maxProcesses: 47, - tmpfsBytes: 256 * MIB, - maxFileBytes: 512 * MIB, - maxOpenFiles: 1024, - writableStorageBytes: 1024 * MIB, - uid: 65534, - gid: 65534, - }), - agent: Object.freeze({ - role: 'agent', - memoryMiB: 768, - vcpuCount: 1, - cpuQuotaMilli: 500, - maxProcesses: 47, - tmpfsBytes: 96 * MIB, - maxFileBytes: 256 * MIB, - maxOpenFiles: 1024, - writableStorageBytes: 512 * MIB, - uid: 65534, - gid: 65534, - }), -}) satisfies Readonly>; +export const CLOUD_HYPERVISOR_ENCLAVE_RESOURCE_PROFILES = ENCLAVE_RESOURCE_POLICIES; export const CLOUD_HYPERVISOR_ENCLAVE_ENTRYPOINTS = Object.freeze({ script: '/usr/local/bin/run-enclave-script', @@ -177,11 +154,7 @@ export function createScriptEnclaveCloudHypervisorProfile(options: { }): CloudHypervisorScriptEnclaveProfile { return sealCloudHypervisorWorkloadProfile({ kind: 'script-enclave', - identity: { - kind: 'script-enclave', - ownerId: options.enclaveId, - invocationId: options.invocationId, - }, + identity: createEnclaveExecutionIdentity('script', options.enclaveId, options.invocationId, 'Cloud Hypervisor'), artifact: 'enclave', network: { mode: 'none' }, exportPlan: options.exportPlan, @@ -220,11 +193,7 @@ export function createAgentEnclaveCloudHypervisorProfile(options: { }): CloudHypervisorAgentEnclaveProfile { return sealCloudHypervisorWorkloadProfile({ kind: 'agent-enclave', - identity: { - kind: 'agent-enclave', - ownerId: options.enclaveId, - invocationId: options.invocationId, - }, + identity: createEnclaveExecutionIdentity('agent', options.enclaveId, options.invocationId, 'Cloud Hypervisor'), artifact: 'enclave', network: { mode: 'enclave-agent', @@ -383,16 +352,6 @@ export function assertCloudHypervisorWorkloadLaunchable( } } -function sameResourceProfile( - actual: CloudHypervisorEnclaveResourceProfile | undefined, - expected: CloudHypervisorEnclaveResourceProfile, -): boolean { - if (!actual || typeof actual !== 'object') return false; - const keys = Object.keys(expected) as (keyof CloudHypervisorEnclaveResourceProfile)[]; - return Object.keys(actual).length === keys.length && - keys.every((key) => actual[key] === expected[key]); -} - function validateGuest(profile: CloudHypervisorWorkloadProfile): void { if (profile.kind === 'primary-agent' && profile.guest === undefined) return; if (!profile.guest || typeof profile.guest !== 'object') { @@ -547,9 +506,7 @@ function validateIp(ip: string, label: string): void { } function assertSafeIdentity(value: string, label: string): void { - if (!SAFE_IDENTITY.test(value)) { - throw new Error(`Unsafe Cloud Hypervisor workload ${label} identity: ${value}`); - } + assertSafeWorkloadIdentity(value, label, 'Cloud Hypervisor'); } function assertClosedObject( diff --git a/src/enclave/bounded-storage.ts b/src/enclave/bounded-storage.ts new file mode 100644 index 000000000..c555a840e --- /dev/null +++ b/src/enclave/bounded-storage.ts @@ -0,0 +1,34 @@ +import { ENCLAVE_RESOURCE_POLICIES, type EnclaveExecutionRole } from './execution-policy'; + +export interface BoundedEnclaveStorageRequirement { + readonly role: EnclaveExecutionRole; + readonly maximumBytes: number; + readonly uid: number; + readonly gid: number; +} + +/** Backends must verify actual confinement and capacity, not available disk space. */ +export type BoundedEnclaveStorageVerifier = ( + directory: string, + requirement: BoundedEnclaveStorageRequirement, + writableDirectories: readonly string[], +) => Promise; + +export function enclaveStorageRequirement(role: EnclaveExecutionRole): BoundedEnclaveStorageRequirement { + const policy = ENCLAVE_RESOURCE_POLICIES[role]; + return Object.freeze({ + role: policy.role, + maximumBytes: policy.writableStorageBytes, + uid: policy.uid, + gid: policy.gid, + }); +} + +export async function verifyBoundedInvocationStorage( + role: EnclaveExecutionRole, + directory: string, + verifier: BoundedEnclaveStorageVerifier, + writableDirectories: readonly string[] = [], +): Promise { + await verifier(directory, enclaveStorageRequirement(role), writableDirectories); +} diff --git a/src/enclave/cloud-hypervisor-host-service.ts b/src/enclave/cloud-hypervisor-host-service.ts index 3bde89d73..a48c1b028 100644 --- a/src/enclave/cloud-hypervisor-host-service.ts +++ b/src/enclave/cloud-hypervisor-host-service.ts @@ -3,10 +3,8 @@ import { type CreateCloudHypervisorHostEnclaveExecutorOptions, type HostEnclaveExecutorDependencies, } from '../cloud-hypervisor/host-enclave-executor'; -import { - startHostExecutorServer, - type HostExecutorServer, -} from './host-executor-server'; +import type { HostExecutorServer } from './host-executor-server'; +import { startHostExecutorService } from './host-service'; export interface CloudHypervisorEnclaveHostServiceOptions extends CreateCloudHypervisorHostEnclaveExecutorOptions { @@ -24,18 +22,13 @@ export interface CloudHypervisorEnclaveHostServiceOptions export async function startCloudHypervisorEnclaveHostService( options: CloudHypervisorEnclaveHostServiceOptions, ): Promise { - const backend = options.backendDependencies - ? await createCloudHypervisorHostEnclaveExecutor(options, options.backendDependencies) - : await createCloudHypervisorHostEnclaveExecutor(options); - try { - options.onPreflightStage?.('host-service'); - return await startHostExecutorServer({ - runtimeDir: options.runtimeDir, - runState: options.runState, - backend, - }); - } catch { - await backend.close().catch(() => undefined); - throw new Error('Cloud Hypervisor enclave host service could not start'); - } + return startHostExecutorService({ + runtimeDir: options.runtimeDir, + runState: options.runState, + createBackend: () => options.backendDependencies + ? createCloudHypervisorHostEnclaveExecutor(options, options.backendDependencies) + : createCloudHypervisorHostEnclaveExecutor(options), + onListening: () => options.onPreflightStage?.('host-service'), + startupFailureMessage: 'Cloud Hypervisor enclave host service could not start', + }); } diff --git a/src/enclave/cloud-hypervisor-lifecycle.test.ts b/src/enclave/cloud-hypervisor-lifecycle.test.ts index e0d184094..d2fc32b60 100644 --- a/src/enclave/cloud-hypervisor-lifecycle.test.ts +++ b/src/enclave/cloud-hypervisor-lifecycle.test.ts @@ -8,6 +8,7 @@ import { startHostExecutorServer, type HostExecutorServer } from './host-executo import { assertAgentRuntimeAvailable, assertScriptRuntimeAvailable } from './runtime-preflight'; import { startCloudHypervisorEnclaveHostService } from './cloud-hypervisor-host-service'; import { getEnclaveStartupProgress } from './startup-progress'; +import { deriveHostExecutorRunState } from './host-executor-run-state'; import { HostExecutorInvocationDiagnostics, HostExecutorControlDiagnostics, readHostExecutorInvocationDiagnostics, } from './host-executor-journal'; @@ -192,6 +193,15 @@ describe('trusted Cloud Hypervisor enclave lifecycle', () => { .toEqual([deriveEnclaveSeedId(runId, repository.repo), seedId]); }); + it('derives the same static host authority independently of the backend adapter', () => { + const paths = resolveEnclavePaths(workDir); + const expected = deriveCloudHypervisorEnclaveRunState(config, paths); + config.containerRuntime = undefined; + config.enclaves!.executors.script.runtime = 'docker'; + config.enclaves!.executors.agent.runtime = 'docker'; + expect(deriveHostExecutorRunState(config, paths)).toEqual(expected); + }); + it.each(['wrong-run', 'unknown-repo', 'wrong-seed', 'missing-repo'])( 'rejects %s staged catalog before storage or listener effects', async (failure) => { const paths = resolveEnclavePaths(workDir); diff --git a/src/enclave/cloud-hypervisor-lifecycle.ts b/src/enclave/cloud-hypervisor-lifecycle.ts index 8c9cfdcb3..d421c2aa0 100644 --- a/src/enclave/cloud-hypervisor-lifecycle.ts +++ b/src/enclave/cloud-hypervisor-lifecycle.ts @@ -1,8 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; import type { WrapperConfig } from '../types'; -import { PRIVATE_REPOSITORY_SEED_MAP_VERSION, type PrivateRepositorySeedMap } from '../bounded-execution'; -import { normalizePrivateRepositoryKey } from '../bounded-execution/repository-staging'; import type { CloudHypervisorManagerDependencies } from '../cloud-hypervisor/manager-types'; import type { HostEnclaveExecutorDependencies, @@ -12,10 +10,10 @@ import { startCloudHypervisorEnclaveHostService, type CloudHypervisorEnclaveHostServiceOptions, } from './cloud-hypervisor-host-service'; -import type { HostExecutorRunState, HostExecutorServer } from './host-executor-server'; -import { deriveEnclaveSeedId, readEnclaveRunId, resolveEnclavePaths, type EnclavePaths } from './paths'; -import { validateEnclavesStructure } from './preflight'; -import { validateRunSandboxBackend } from './run-backend'; +import type { HostExecutorRunState } from './host-executor-server'; +import { HostExecutorLifecycleRegistry, drainHostExecutorLifecycle } from './host-executor-lifecycle'; +import { deriveHostExecutorRunState } from './host-executor-run-state'; +import { readEnclaveRunId, resolveEnclavePaths, type EnclavePaths } from './paths'; import { updateEnclaveStartupProgress } from './startup-progress'; import { HostPreflightReporter, markHostPreflightError, type HostPreflightProgress, @@ -91,56 +89,7 @@ export function deriveCloudHypervisorEnclaveRunState( if (!config.enclaves || !isCloudHypervisorEnclaveSelected(config)) { throw new Error('Invalid trusted Cloud Hypervisor enclave configuration: no Cloud Hypervisor executor'); } - const errors = [ - ...validateRunSandboxBackend(config.containerRuntime, config.enclaves), - ...validateEnclavesStructure(config, { requireDelegationHandoff: false }), - ]; - if (errors.length) { - throw new Error(`Invalid trusted Cloud Hypervisor enclave configuration: ${errors.join('; ')}`); - } - const runId = readEnclaveRunId(paths); - if (!runId) throw new Error('Cloud Hypervisor enclaves require a staged trusted run ID'); - const seedMap = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')) as PrivateRepositorySeedMap; - if (seedMap.version !== PRIVATE_REPOSITORY_SEED_MAP_VERSION - || seedMap.runId !== runId || !Array.isArray(seedMap.seeds)) { - throw new Error('Cloud Hypervisor enclave seed map does not match the trusted run'); - } - const catalog = new Map(config.enclaves!.privateRepos.map((repo) => [ - normalizePrivateRepositoryKey(repo.repo), repo, - ])); - const seeds = new Map(); - for (const seed of seedMap.seeds) { - const repo = catalog.get(seed.repo); - if (!repo || seeds.has(seed.repo) || seed.sensitivity !== repo.sensitivity - || seed.seedId !== deriveEnclaveSeedId(runId, seed.repo)) { - throw new Error('Cloud Hypervisor enclave seed map differs from the validated catalog'); - } - const stat = fs.lstatSync(path.join(paths.seedsDir, seed.seedId)); - if (!stat.isDirectory() || stat.isSymbolicLink()) { - throw new Error('Cloud Hypervisor enclave seeds must be staged real directories'); - } - seeds.set(seed.repo, seed.seedId); - } - if (seeds.size !== catalog.size) throw new Error('Cloud Hypervisor enclave seed catalog is incomplete'); - return { - runId, - seedsDir: paths.seedsDir, - invocationsDir: path.join(path.dirname(paths.hostExecutorJournalDir), 'host-invocations', runId), - journalDir: paths.hostExecutorJournalDir, - entries: Object.entries(config.enclaves!.executors) - .filter(([, entry]) => entry.enabled) - .map(([entryId, entry]) => ({ - entryId, - executorKind: entryId as 'script' | 'agent', - timeoutMs: entry.timeout * 1000, - staticSeedIds: config.enclaves!.privateRepos.map((repo) => { - const seed = seeds.get(normalizePrivateRepositoryKey(repo.repo)); - if (!seed) throw new Error('Cloud Hypervisor enclave entry references an unstaged repository'); - return seed; - }), - dynamicAgents: false, - })), - }; + return deriveHostExecutorRunState(config, paths, 'Cloud Hypervisor enclave'); } function deriveAgentPolicies( @@ -160,19 +109,9 @@ function deriveAgentPolicies( }; } -interface RunLifecycle { - configuration: WrapperConfig; - runId: string; - server?: HostExecutorServer; - storage?: Awaited>; - failed?: boolean; - closePromise?: Promise; - startPromise?: Promise; - draining?: boolean; - publicDebugVerified?: boolean; -} -const runs = new Map(); -const completedRuns = new Map(); +const lifecycle = new HostExecutorLifecycleRegistry< + WrapperConfig, Awaited> +>('Cloud Hypervisor enclave'); export async function startCloudHypervisorEnclaveLifecycle( config: WrapperConfig, @@ -180,7 +119,7 @@ export async function startCloudHypervisorEnclaveLifecycle( environment: NodeJS.ProcessEnv = process.env, ): Promise { const paths = resolveEnclavePaths(config.workDir); - if (runs.has(paths.root)) throw new Error('Cloud Hypervisor enclave host executor already exists for this run'); + if (lifecycle.has(paths.root)) throw new Error('Cloud Hypervisor enclave host executor already exists for this run'); if (!config.cloudHypervisor) throw new Error('Cloud Hypervisor enclave runtime configuration is missing'); const debugConfig = resolvePublicEnclaveDebugConfig(config); let options: CloudHypervisorEnclaveHostServiceOptions = { @@ -193,61 +132,43 @@ export async function startCloudHypervisorEnclaveLifecycle( onPreflightStage: (stage) => updateEnclaveStartupProgress(config, { stage }), onHostPreflight: (hostPreflight) => updateEnclaveStartupProgress(config, { hostPreflight }), }; - const run: RunLifecycle = { configuration: config, runId: options.runState.runId }; - runs.set(paths.root, run); - run.startPromise = (async () => { - try { - updateEnclaveStartupProgress(config, { stage: 'host-preflight' }); - if (debugConfig.cloudHypervisor!.enclaveDebugPublic) { - try { - await verifyPublicEnclaveDebugRepositories(debugConfig); - run.publicDebugVerified = true; - options = { - ...options, - publicDebugArtifactDirectory: ENCLAVE_DEBUG_ARTIFACT_ROOT, - publicDebugArtifactProtectedRoot: ENCLAVE_DEBUG_ARTIFACT_PROTECTED_ROOT, - }; - logger.warn(`Public enclave debug retains unredacted raw logs in ${ENCLAVE_DEBUG_ARTIFACT_ROOT}; do not publish these artifacts.`); - } catch (error) { - if (!(error instanceof PublicDebugVisibilityError)) throw error; - options = { ...options, config: { ...options.config, enclaveDebugPublic: false } }; - logger.warn(`Public enclave debug capture and export disabled: ${error.message}. Enclave execution will continue without public debug artifacts.`); - } + await lifecycle.start(paths.root, config, options.runState.runId, async (run) => { + updateEnclaveStartupProgress(config, { stage: 'host-preflight' }); + if (debugConfig.cloudHypervisor!.enclaveDebugPublic) { + try { + await verifyPublicEnclaveDebugRepositories(debugConfig); + run.publicDebugVerified = true; + options = { + ...options, + publicDebugArtifactDirectory: ENCLAVE_DEBUG_ARTIFACT_ROOT, + publicDebugArtifactProtectedRoot: ENCLAVE_DEBUG_ARTIFACT_PROTECTED_ROOT, + }; + logger.warn(`Public enclave debug retains unredacted raw logs in ${ENCLAVE_DEBUG_ARTIFACT_ROOT}; do not publish these artifacts.`); + } catch (error) { + if (!(error instanceof PublicDebugVisibilityError)) throw error; + options = { ...options, config: { ...options.config, enclaveDebugPublic: false } }; + logger.warn(`Public enclave debug capture and export disabled: ${error.message}. Enclave execution will continue without public debug artifacts.`); } - await assertCloudHypervisorEnclavePrerequisites(config, storageProvider); - updateEnclaveStartupProgress(config, { stage: 'storage-preflight' }); - run.storage = await storageProvider.prepareRun(options); - updateEnclaveStartupProgress(config, { stage: 'host-service' }); - run.server = await startCloudHypervisorEnclaveHostService({ - ...options, - backendDependencies: run.storage.backendDependencies, - managerDependencies: run.storage.managerDependencies, - }); - updateEnclaveStartupProgress(config, { stage: 'host-service' }); - if (run.draining) run.server.closeAdmissions(); - } catch (error) { - run.failed = true; - throw error; } - })(); - await run.startPromise; + await assertCloudHypervisorEnclavePrerequisites(config, storageProvider); + updateEnclaveStartupProgress(config, { stage: 'storage-preflight' }); + run.storage = await storageProvider.prepareRun(options); + updateEnclaveStartupProgress(config, { stage: 'host-service' }); + run.server = await startCloudHypervisorEnclaveHostService({ + ...options, + backendDependencies: run.storage.backendDependencies, + managerDependencies: run.storage.managerDependencies, + }); + updateEnclaveStartupProgress(config, { stage: 'host-service' }); + }); } export function closeCloudHypervisorEnclaveAdmissions(config: WrapperConfig): void { - const run = runs.get(resolveEnclavePaths(config.workDir).root); - if (!run) return; - if (run.configuration !== config) { - throw new Error('Cloud Hypervisor enclave lifecycle is owned by another configuration'); - } - run.draining = true; - run.server?.closeAdmissions(); + lifecycle.closeAdmissions(resolveEnclavePaths(config.workDir).root, config); } export function assertCloudHypervisorEnclaveLifecycleReady(config: WrapperConfig): Promise { - const run = runs.get(resolveEnclavePaths(config.workDir).root); - return run?.configuration === config && run.server && !run.failed && !run.draining && !run.closePromise - ? Promise.resolve() - : Promise.reject(new Error('Cloud Hypervisor enclave trusted full preflight has not completed')); + return lifecycle.ready(resolveEnclavePaths(config.workDir).root, config); } function assertRecoveryComplete(paths: EnclavePaths, runId: string): void { @@ -287,12 +208,11 @@ function assertRecoveryComplete(paths: EnclavePaths, runId: string): void { export async function stopCloudHypervisorEnclaveLifecycle(config: WrapperConfig): Promise { const paths = resolveEnclavePaths(config.workDir); - const run = runs.get(paths.root); - if (!run && !config.enclaves?.enabled) return; - if (!run) { + if (!lifecycle.has(paths.root) && !config.enclaves?.enabled) return; + await lifecycle.stop(paths.root, config, () => { // A restarted host must not delete resource recovery records it did not reap. const runId = readEnclaveRunId(paths); - if (runId && completedRuns.get(paths.root) === runId) return; + if (runId && lifecycle.completed(paths.root, runId)) return; if (runId && fs.existsSync(paths.hostExecutorJournalDir) && fs.readdirSync(paths.hostExecutorJournalDir) .some((file) => file === `${runId}.journal` || file.startsWith(`${runId}-`))) { @@ -302,13 +222,7 @@ export async function stopCloudHypervisorEnclaveLifecycle(config: WrapperConfig) throw new Error('Cloud Hypervisor enclave run identity is missing; private recovery state is preserved'); } return; - } - closeCloudHypervisorEnclaveAdmissions(config); - run.closePromise ??= (async () => { - await run.startPromise?.catch(() => undefined); - let cleanupError: unknown; - let evidenceError: unknown; - let hostDrained = false; + }, async (run) => { const preserveEvidence = async (): Promise => { const evidence = readHostExecutorInvocationDiagnostics({ runId: run.runId, journalDir: paths.hostExecutorJournalDir, @@ -319,68 +233,52 @@ export async function stopCloudHypervisorEnclaveLifecycle(config: WrapperConfig) resolveCloudHypervisorManagerDependencies(), ); }; - try { - await preserveEvidence(); - } catch (error) { - evidenceError = error; - } - try { - await run.server?.close(); - assertRecoveryComplete(paths, run.runId); - hostDrained = Boolean(run.server); - } catch (error) { - cleanupError = error; - } - // The dedicated proxy is shared across invocations. Capture only run-wide - // counters after host execution drains, before compose/private-root teardown. - try { - persistHostExecutorProxyUsage({ - runId: run.runId, journalDir: paths.hostExecutorJournalDir, - }, !config.enclaves?.executors.agent.enabled - ? unavailableEnclaveProxyUsage('not-applicable') - : hostDrained - ? await readEnclaveProxyUsage(paths.apiProxyLogsDir) - : unavailableEnclaveProxyUsage('not-drained')); - } catch (error) { - evidenceError ??= error; - } - if (run.publicDebugVerified && config.enclaves?.executors.agent.enabled) { - try { - const identity = { runId: run.runId, journalDir: paths.hostExecutorJournalDir }; - const { diagnostics, events } = hostDrained - ? await readEnclaveProxyEventEvidence(paths.apiProxyLogsDir) - : { - diagnostics: { scope: 'run-cumulative' as const, status: 'not-drained' as const }, - events: [], - }; - persistHostExecutorProxyResponseDiagnostics(identity, diagnostics); - await preservePublicEnclaveProxyDiagnostics( - identity, diagnostics, events, await captureEnclaveProxyContainerLog(), - ); - } catch (error) { - evidenceError ??= error; - } - } - if (cleanupError === undefined) { - try { + await drainHostExecutorLifecycle({ + preserveEvidence, + drain: async () => { + await run.server?.close(); + assertRecoveryComplete(paths, run.runId); + return Boolean(run.server); + }, + // The dedicated proxy is shared across invocations. Capture only run-wide + // counters after host execution drains, before compose/private-root teardown. + collectDiagnostics: async (hostDrained) => { + let evidenceError: unknown; + try { + persistHostExecutorProxyUsage({ + runId: run.runId, journalDir: paths.hostExecutorJournalDir, + }, !config.enclaves?.executors.agent.enabled + ? unavailableEnclaveProxyUsage('not-applicable') + : hostDrained + ? await readEnclaveProxyUsage(paths.apiProxyLogsDir) + : unavailableEnclaveProxyUsage('not-drained')); + } catch (error) { + evidenceError = error; + } + if (run.publicDebugVerified && config.enclaves?.executors.agent.enabled) { + try { + const identity = { runId: run.runId, journalDir: paths.hostExecutorJournalDir }; + const { diagnostics, events } = hostDrained + ? await readEnclaveProxyEventEvidence(paths.apiProxyLogsDir) + : { + diagnostics: { scope: 'run-cumulative' as const, status: 'not-drained' as const }, + events: [], + }; + persistHostExecutorProxyResponseDiagnostics(identity, diagnostics); + await preservePublicEnclaveProxyDiagnostics( + identity, diagnostics, events, await captureEnclaveProxyContainerLog(), + ); + } catch (error) { + evidenceError ??= error; + } + } + if (evidenceError !== undefined) throw evidenceError; + }, + releaseStorage: async () => { await run.storage?.close(); if (run.failed) throw new Error('Cloud Hypervisor enclave startup failed; private recovery state is preserved'); - } catch (error) { - cleanupError = error; - } - } - try { - await preserveEvidence(); - } catch (error) { - evidenceError ??= error; - } - if (evidenceError !== undefined) { - if (cleanupError !== undefined) throw new HostPreflightCleanupError(cleanupError, evidenceError); - throw evidenceError; - } - if (cleanupError !== undefined) throw cleanupError; - completedRuns.set(paths.root, run.runId); - runs.delete(paths.root); - })(); - await run.closePromise; + }, + combineErrors: (cleanupError, evidenceError) => new HostPreflightCleanupError(cleanupError, evidenceError), + }); + }); } diff --git a/src/enclave/execution-policy.test.ts b/src/enclave/execution-policy.test.ts new file mode 100644 index 000000000..561578492 --- /dev/null +++ b/src/enclave/execution-policy.test.ts @@ -0,0 +1,79 @@ +import { + ENCLAVE_RESOURCE_POLICIES, + createEnclaveExecutionIdentity, + normalizeAgentPolicy, + sameEnclaveResourcePolicy, + type HostExecutorAgentPolicy, +} from './execution-policy'; +import { enclaveStorageRequirement, verifyBoundedInvocationStorage } from './bounded-storage'; +import { HOST_EXECUTOR_MAX_RESULT_BYTES } from './host-executor-protocol'; + +const agentPolicy: HostExecutorAgentPolicy = { + model: 'trusted-model', + profile: 'openai', + maxOutputBytes: 4096, +}; + +describe('backend-neutral enclave execution policy', () => { + it.each(['script', 'agent'] as const)('keeps the %s role resource and storage policies closed', async (role) => { + const profile = ENCLAVE_RESOURCE_POLICIES[role]; + expect(Object.isFrozen(ENCLAVE_RESOURCE_POLICIES)).toBe(true); + expect(Object.isFrozen(profile)).toBe(true); + expect(profile).toMatchObject({ role, uid: 65534, gid: 65534, vcpuCount: 1, cpuQuotaMilli: 500 }); + expect(sameEnclaveResourcePolicy({ ...profile }, profile)).toBe(true); + expect(sameEnclaveResourcePolicy({ ...profile, uid: 0 }, profile)).toBe(false); + expect(sameEnclaveResourcePolicy({ ...profile, extra: true } as never, profile)).toBe(false); + expect(sameEnclaveResourcePolicy(undefined, profile)).toBe(false); + const requirement = enclaveStorageRequirement(role); + expect(Object.isFrozen(requirement)).toBe(true); + expect(requirement).toEqual({ + role, maximumBytes: profile.writableStorageBytes, uid: profile.uid, gid: profile.gid, + }); + const verify = jest.fn().mockResolvedValue(undefined); + await verifyBoundedInvocationStorage(role, '/trusted/invocation', verify, ['/trusted/invocation/output']); + expect(verify).toHaveBeenCalledWith('/trusted/invocation', requirement, ['/trusted/invocation/output']); + verify.mockRejectedValue(new Error('capacity could not be verified')); + await expect(verifyBoundedInvocationStorage(role, '/trusted/invocation', verify)) + .rejects.toThrow('capacity could not be verified'); + }); + + it.each(['script', 'agent'] as const)('derives a frozen %s identity from host identifiers', (role) => { + const identity = createEnclaveExecutionIdentity(role, 'trusted-entry', 'invocation.1'); + expect(identity).toEqual({ kind: `${role}-enclave`, ownerId: 'trusted-entry', invocationId: 'invocation.1' }); + expect(Object.isFrozen(identity)).toBe(true); + expect(() => createEnclaveExecutionIdentity(role, '../entry', 'invocation')).toThrow(/owner identity/); + expect(() => createEnclaveExecutionIdentity(role, 'entry', 'bad/invocation')).toThrow(/invocation identity/); + }); + + it('accepts absent script policies and valid trusted agent settings without changing them', () => { + expect(normalizeAgentPolicy(undefined, 1000)).toBeUndefined(); + expect(normalizeAgentPolicy(agentPolicy, 1000)).toBe(agentPolicy); + expect(normalizeAgentPolicy({ + ...agentPolicy, maxModelRequests: 1, maxModelTokens: 128, + githubAgentId: 'entry-1', githubBearer: 'x'.repeat(32), + }, 1000)).toMatchObject({ githubAgentId: 'entry-1' }); + }); + + it.each([ + { model: '' }, { model: 'x'.repeat(257) }, { model: 'é'.repeat(129) }, { model: 'model\n' }, + { profile: 'unknown' }, { maxOutputBytes: 0 }, { maxOutputBytes: HOST_EXECUTOR_MAX_RESULT_BYTES + 1 }, + { maxOutputBytes: 1.5 }, { maxModelRequests: 0 }, { maxModelTokens: -1 }, + ])('rejects invalid trusted agent settings: %j', (override) => { + expect(() => normalizeAgentPolicy({ ...agentPolicy, ...override } as HostExecutorAgentPolicy, 1000)) + .toThrow(/agent policy is invalid/); + }); + + it.each([0, -1, 1.5, NaN])('rejects an invalid trusted deadline %s', (timeout) => { + expect(() => normalizeAgentPolicy(agentPolicy, timeout)).toThrow(/agent policy is invalid/); + }); + + it.each([ + { githubAgentId: 'agent' }, + { githubBearer: 'x'.repeat(32) }, + { githubAgentId: '../agent', githubBearer: 'x'.repeat(32) }, + { githubAgentId: 'agent', githubBearer: 'short' }, + { githubAgentId: 'agent', githubBearer: `${'x'.repeat(32)}\n` }, + ])('rejects partial or malformed GitHub credential handoffs: %j', (credentials) => { + expect(() => normalizeAgentPolicy({ ...agentPolicy, ...credentials }, 1000)).toThrow(/GitHub/); + }); +}); diff --git a/src/enclave/execution-policy.ts b/src/enclave/execution-policy.ts new file mode 100644 index 000000000..ae65e8194 --- /dev/null +++ b/src/enclave/execution-policy.ts @@ -0,0 +1,148 @@ +import type { EnclaveAgentProfile } from '../types/enclave-options'; +import { HOST_EXECUTOR_MAX_RESULT_BYTES } from './host-executor-protocol'; + +export type EnclaveExecutionRole = 'script' | 'agent'; + +export interface EnclaveResourcePolicy { + readonly role: EnclaveExecutionRole; + readonly memoryMiB: number; + readonly vcpuCount: number; + readonly cpuQuotaMilli: number; + readonly maxProcesses: number; + readonly tmpfsBytes: number; + readonly maxFileBytes: number; + readonly maxOpenFiles: number; + readonly writableStorageBytes: number; + readonly uid: number; + readonly gid: number; +} + +export interface EnclaveExecutionIdentity { + readonly kind: `${Role}-enclave`; + readonly ownerId: string; + readonly invocationId: string; +} + +export interface HostExecutorAgentPolicy { + readonly model: string; + readonly profile: EnclaveAgentProfile; + readonly maxOutputBytes: number; + readonly maxModelRequests?: number; + readonly maxModelTokens?: number; + readonly modelBudgetSecret?: string; + readonly githubAgentId?: string; + readonly githubBearer?: string; +} + +const MIB = 1024 * 1024; +const SAFE_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/; +const CREDENTIAL_PATTERN = /^[\x21-\x7e]{16,512}$/; +const AGENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +/** Trusted role limits, never overlaid with invocation-supplied settings. */ +export const ENCLAVE_RESOURCE_POLICIES = Object.freeze({ + script: Object.freeze({ + role: 'script', + memoryMiB: 768, + vcpuCount: 1, + cpuQuotaMilli: 500, + maxProcesses: 47, + tmpfsBytes: 256 * MIB, + maxFileBytes: 512 * MIB, + maxOpenFiles: 1024, + writableStorageBytes: 1024 * MIB, + uid: 65534, + gid: 65534, + }), + agent: Object.freeze({ + role: 'agent', + memoryMiB: 768, + vcpuCount: 1, + cpuQuotaMilli: 500, + maxProcesses: 47, + tmpfsBytes: 96 * MIB, + maxFileBytes: 256 * MIB, + maxOpenFiles: 1024, + writableStorageBytes: 512 * MIB, + uid: 65534, + gid: 65534, + }), +}) satisfies Readonly>; + +export function assertSafeWorkloadIdentity(value: string, label: string, context = 'enclave'): void { + if (!SAFE_IDENTITY.test(value)) { + throw new Error(`Unsafe ${context} workload ${label} identity: ${value}`); + } +} + +export function createEnclaveExecutionIdentity( + role: Role, + ownerId: string, + invocationId: string, + context = 'enclave', +): EnclaveExecutionIdentity { + assertSafeWorkloadIdentity(ownerId, 'owner', context); + assertSafeWorkloadIdentity(invocationId, 'invocation', context); + return Object.freeze({ + kind: `${role}-enclave` as const, + ownerId, + invocationId, + }); +} + +export function sameEnclaveResourcePolicy( + actual: EnclaveResourcePolicy | undefined, + expected: EnclaveResourcePolicy, +): boolean { + if (!actual || typeof actual !== 'object') return false; + const keys = Object.keys(expected) as (keyof EnclaveResourcePolicy)[]; + return Object.keys(actual).length === keys.length && + keys.every((key) => actual[key] === expected[key]); +} + +function hasControlCharacters(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +} + +export function normalizeAgentPolicy( + policy: HostExecutorAgentPolicy | undefined, + timeoutMs: number, +): HostExecutorAgentPolicy | undefined { + if (!policy) return undefined; + if ( + typeof policy.model !== 'string' || + policy.model.length === 0 || + Buffer.byteLength(policy.model, 'utf8') > 256 || + hasControlCharacters(policy.model) || + !['openai', 'anthropic'].includes(policy.profile) || + !Number.isSafeInteger(policy.maxOutputBytes) || + policy.maxOutputBytes < 1 || + policy.maxOutputBytes > HOST_EXECUTOR_MAX_RESULT_BYTES || + (policy.maxModelRequests !== undefined && + (!Number.isSafeInteger(policy.maxModelRequests) || policy.maxModelRequests < 1)) || + (policy.maxModelTokens !== undefined && + (!Number.isSafeInteger(policy.maxModelTokens) || policy.maxModelTokens < 1)) || + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 1 + ) { + throw new Error('Trusted host executor agent policy is invalid'); + } + const hasGithubId = policy.githubAgentId !== undefined; + const hasGithubBearer = policy.githubBearer !== undefined; + if (hasGithubId !== hasGithubBearer) { + throw new Error('Trusted GitHub agent identity and bearer must be configured together'); + } + if ( + (policy.githubAgentId !== undefined && !AGENT_ID_PATTERN.test(policy.githubAgentId)) || + (policy.githubBearer !== undefined && + (!CREDENTIAL_PATTERN.test(policy.githubBearer) || + Buffer.byteLength(policy.githubBearer, 'ascii') > 512)) + ) { + throw new Error('Trusted host executor GitHub credentials are invalid'); + } + return policy; +} diff --git a/src/enclave/host-executor-lifecycle.test.ts b/src/enclave/host-executor-lifecycle.test.ts new file mode 100644 index 000000000..9d851dc59 --- /dev/null +++ b/src/enclave/host-executor-lifecycle.test.ts @@ -0,0 +1,156 @@ +import { + HostExecutorLifecycleRegistry, drainHostExecutorLifecycle, type HostExecutorDrainHooks, + type HostExecutorRunLifecycle, +} from './host-executor-lifecycle'; +import type { HostExecutorServer } from './host-executor-server'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +describe('backend-neutral host executor lifecycle', () => { + const owner = {}; + let registry: HostExecutorLifecycleRegistry }>; + let server: HostExecutorServer; + + beforeEach(() => { + registry = new HostExecutorLifecycleRegistry('Test enclave'); + server = { + socketPath: '/private/executor.sock', capabilityPath: '/private/capability', + closeAdmissions: jest.fn(), close: jest.fn().mockResolvedValue(undefined), + }; + }); + + it('reports host-service readiness without requiring persistent workload readiness', async () => { + await expect(registry.ready('/run', owner)).rejects.toThrow(/full preflight/); + const starting = deferred(); + const start = registry.start('/run', owner, 'run-id', async (run) => { + await starting.promise; + run.server = server; + }); + await expect(registry.ready('/run', owner)).rejects.toThrow(/full preflight/); + starting.resolve(); + await start; + await expect(registry.ready('/run', owner)).resolves.toBeUndefined(); + await expect(registry.ready('/run', {})).rejects.toThrow(/full preflight/); + }); + + it('closes admissions when shutdown races startup and shares draining', async () => { + const starting = deferred(); + const stopping = deferred(); + const start = registry.start('/run', owner, 'run-id', async (run) => { + await starting.promise; + run.server = server; + }); + const close = jest.fn(async (run: HostExecutorRunLifecycle }>) => { + expect(run.draining).toBe(true); + await run.server!.close(); + await stopping.promise; + }); + const stop = registry.stop('/run', owner, jest.fn(), close); + const again = registry.stop('/run', owner, jest.fn(), close); + expect(close).not.toHaveBeenCalled(); + await expect(registry.ready('/run', owner)).rejects.toThrow(/full preflight/); + starting.resolve(); + await start; + expect(server.closeAdmissions).toHaveBeenCalled(); + stopping.resolve(); + await Promise.all([stop, again]); + expect(close).toHaveBeenCalledTimes(1); + expect(registry.completed('/run', 'run-id')).toBe(true); + expect(registry.completed('/run', 'other-run')).toBe(false); + expect(registry.has('/run')).toBe(false); + }); + + it('does not let a different configuration close an owned run', async () => { + await registry.start('/run', owner, 'run-id', async (run) => { run.server = server; }); + expect(() => registry.closeAdmissions('/run', {})).toThrow(/owned by another/); + await expect(registry.stop('/run', {}, jest.fn(), jest.fn())).rejects.toThrow(/owned by another/); + await expect(registry.ready('/run', owner)).resolves.toBeUndefined(); + expect(server.closeAdmissions).not.toHaveBeenCalled(); + }); + + it('retains failed startup state and awaits it before cleanup', async () => { + const starting = deferred(); + const start = registry.start('/run', owner, 'run-id', async () => { + await starting.promise; + throw new Error('startup failed'); + }); + const failed = expect(start).rejects.toThrow('startup failed'); + const close = jest.fn(async (run: HostExecutorRunLifecycle }>) => { + expect(run.failed).toBe(true); + throw new Error('recovery required'); + }); + const stop = registry.stop('/run', owner, jest.fn(), close); + starting.resolve(); + await failed; + await expect(stop).rejects.toThrow('recovery required'); + expect(registry.has('/run')).toBe(true); + expect(registry.completed('/run', 'run-id')).toBe(false); + await expect(registry.start('/run', owner, 'new-id', jest.fn())).rejects.toThrow(/already exists/); + await expect(registry.stop('/run', owner, jest.fn(), close)).rejects.toThrow('recovery required'); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('requires adapter reconciliation when no in-memory run exists', async () => { + await expect(registry.stop('/run', owner, () => { + throw new Error('unreaped journals'); + }, jest.fn())).rejects.toThrow('unreaped journals'); + }); + + it('publishes shutdown state before a synchronous admission-close callback reenters', async () => { + await registry.start('/run', owner, 'run-id', async (run) => { run.server = server; }); + let readiness: Promise | undefined; + server.closeAdmissions = () => { + readiness = registry.ready('/run', owner); + void readiness.catch(() => undefined); + }; + await registry.stop('/run', owner, jest.fn(), async () => undefined); + await expect(readiness).rejects.toThrow(/full preflight/); + }); +}); + +describe('backend-neutral drain, recovery and protected evidence ordering', () => { + let events: string[]; + let hooks: HostExecutorDrainHooks; + beforeEach(() => { + events = []; + hooks = { + preserveEvidence: async () => { events.push('evidence'); }, + drain: async () => { events.push('drain-and-reconcile'); return true; }, + collectDiagnostics: async (drained) => { events.push(`diagnostics:${drained}`); }, + releaseStorage: async () => { events.push('release'); }, + combineErrors: (cleanup, evidence) => Object.assign(new Error('cleanup and evidence failed'), { + errors: [cleanup, evidence], + }), + }; + }); + + it('reconciles resources before releasing storage, then preserves final evidence', async () => { + await drainHostExecutorLifecycle(hooks); + expect(events).toEqual(['evidence', 'drain-and-reconcile', 'diagnostics:true', 'release', 'evidence']); + }); + + it('retains storage on incomplete cleanup and still captures protected diagnostics', async () => { + hooks.drain = async () => { throw new Error('uncertain cleanup'); }; + await expect(drainHostExecutorLifecycle(hooks)).rejects.toThrow('uncertain cleanup'); + expect(events).toEqual(['evidence', 'diagnostics:false', 'evidence']); + }); + + it('still drains and preserves both failures when evidence capture fails', async () => { + const cleanup = new Error('uncertain cleanup'); + const evidence = new Error('evidence failure'); + hooks.preserveEvidence = async () => { events.push('evidence'); throw evidence; }; + hooks.drain = async () => { events.push('drain'); throw cleanup; }; + await expect(drainHostExecutorLifecycle(hooks)).rejects.toMatchObject({ errors: [cleanup, evidence] }); + expect(events).toEqual(['evidence', 'drain', 'diagnostics:false', 'evidence']); + }); + + it('does not mistake diagnostics failure for unreaped backend resources', async () => { + hooks.collectDiagnostics = async () => { throw new Error('diagnostics failure'); }; + await expect(drainHostExecutorLifecycle(hooks)).rejects.toThrow('diagnostics failure'); + expect(events).toEqual(['evidence', 'drain-and-reconcile', 'release', 'evidence']); + }); +}); diff --git a/src/enclave/host-executor-lifecycle.ts b/src/enclave/host-executor-lifecycle.ts new file mode 100644 index 000000000..ff1c477f4 --- /dev/null +++ b/src/enclave/host-executor-lifecycle.ts @@ -0,0 +1,127 @@ +import type { HostExecutorServer } from './host-executor-server'; + +export interface HostExecutorRunLifecycle { + readonly configuration: Owner; + readonly runId: string; + server?: HostExecutorServer; + storage?: Storage; + failed?: boolean; + closePromise?: Promise; + startPromise?: Promise; + draining?: boolean; + publicDebugVerified?: boolean; +} + +/** Owns host services, not primary workload managers or persistent guest readiness. */ +export class HostExecutorLifecycleRegistry { + private readonly runs = new Map>(); + private readonly completedRuns = new Map(); + + constructor(private readonly label: string) {} + + has(key: string): boolean { return this.runs.has(key); } + + completed(key: string, runId: string): boolean { + return this.completedRuns.get(key) === runId; + } + + async start( + key: string, + configuration: Owner, + runId: string, + start: (run: HostExecutorRunLifecycle) => Promise, + ): Promise { + if (this.runs.has(key)) throw new Error(`${this.label} host executor already exists for this run`); + const run: HostExecutorRunLifecycle = { configuration, runId }; + this.runs.set(key, run); + // Publish the promise before calling adapter code, including synchronous callbacks. + let resolveStart!: () => void; + let rejectStart!: (error: unknown) => void; + run.startPromise = new Promise((resolve, reject) => { + resolveStart = resolve; + rejectStart = reject; + }); + void (async () => { + try { + await start(run); + if (run.draining) run.server?.closeAdmissions(); + } catch (error) { + run.failed = true; + throw error; + } + })().then(resolveStart, rejectStart); + await run.startPromise; + } + + closeAdmissions(key: string, configuration: Owner): void { + const run = this.runs.get(key); + if (!run) return; + this.assertOwner(run, configuration); + run.draining = true; + run.server?.closeAdmissions(); + } + + ready(key: string, configuration: Owner): Promise { + const run = this.runs.get(key); + return run?.configuration === configuration && run.server + && !run.failed && !run.draining && !run.closePromise + ? Promise.resolve() + : Promise.reject(new Error(`${this.label} trusted full preflight has not completed`)); + } + + async stop( + key: string, + configuration: Owner, + missing: () => void, + close: (run: HostExecutorRunLifecycle) => Promise, + ): Promise { + const run = this.runs.get(key); + if (!run) return missing(); + this.closeAdmissions(key, configuration); + run.closePromise ??= Promise.resolve().then(async () => { + await run.startPromise?.catch(() => undefined); + await close(run); + this.completedRuns.set(key, run.runId); + this.runs.delete(key); + }); + await run.closePromise; + } + + private assertOwner(run: HostExecutorRunLifecycle, configuration: Owner): void { + if (run.configuration !== configuration) { + throw new Error(`${this.label} lifecycle is owned by another configuration`); + } + } +} + +export interface HostExecutorDrainHooks { + preserveEvidence(): Promise; + /** + * Must verify recovery journals and reject if cleanup is incomplete or uncertain. + * The boolean only reports whether a host service was drained. + */ + drain(): Promise; + collectDiagnostics(drained: boolean): Promise; + /** Runs only after confirmed cleanup; storage authority stays in the adapter. */ + releaseStorage(): Promise; + combineErrors(cleanupError: unknown, evidenceError: unknown): Error; +} + +/** Preserve evidence on both sides of teardown and retain storage on uncertain cleanup. */ +export async function drainHostExecutorLifecycle(hooks: HostExecutorDrainHooks): Promise { + let cleanupError: unknown; + let evidenceError: unknown; + let drained = false; + try { await hooks.preserveEvidence(); } catch (error) { evidenceError = error; } + try { drained = await hooks.drain(); } catch (error) { cleanupError = error; } + try { await hooks.collectDiagnostics(drained); } catch (error) { evidenceError ??= error; } + if (cleanupError === undefined) { + try { await hooks.releaseStorage(); } catch (error) { cleanupError = error; } + } + try { await hooks.preserveEvidence(); } catch (error) { evidenceError ??= error; } + if (evidenceError !== undefined) { + if (cleanupError !== undefined) throw hooks.combineErrors(cleanupError, evidenceError); + throw evidenceError; + } + if (cleanupError !== undefined) throw cleanupError; +} diff --git a/src/enclave/host-executor-routing.test.ts b/src/enclave/host-executor-routing.test.ts new file mode 100644 index 000000000..b53cd46dc --- /dev/null +++ b/src/enclave/host-executor-routing.test.ts @@ -0,0 +1,74 @@ +import { createHash } from 'crypto'; +import { validateSchema } from '../bounded-execution/finite-schema'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { createHostExecutorClient } = require('../../containers/enclave/mcp-server/host-executor-client'); +const { createHostExecutorRunner, usesHostExecutor } = require('../../containers/enclave/mcp-server/host-executor-runner'); +const { loadConfig, loadAgentConfig } = require('../../containers/enclave/mcp-server/config'); +/* eslint-enable @typescript-eslint/no-require-imports */ + +jest.mock('../../containers/enclave/mcp-server/host-executor-client'); + +describe('trusted host transport routing', () => { + beforeEach(() => jest.resetAllMocks()); + + it.each(['docker', 'nvx', 'cloud-hypervisor'])( + 'uses the same protocol for a trusted %s adapter without sending launch controls', async (backend) => { + const digest = createHash('sha256').update(JSON.stringify(['success', 'true'])).digest('hex'); + const client = { + status: jest.fn().mockResolvedValue({ ok: false, error: 'unknown-invocation' }), + invoke: jest.fn().mockResolvedValue({ + ok: true, state: 'terminal', outcome: 'success', result: 'true', + resultDigest: digest, cancelGeneration: 0, + }), + settle: jest.fn().mockResolvedValue({ + ok: true, state: 'settled', outcome: 'success', resultDigest: digest, cancelGeneration: 0, + }), + }; + createHostExecutorClient.mockReturnValue(client); + const config = { + executorBackend: backend, executorTransport: 'host', entryId: 'script', + runId: 'a'.repeat(32), hostExecutorSocketPath: '/private/executor.sock', + hostExecutorCapabilityPath: '/private/capability', + }; + expect(usesHostExecutor(config)).toBe(true); + const runner = createHostExecutorRunner(config, { nowMs: () => 0 }); + await runner.assertAvailable(); + const parsed = validateSchema({ type: 'boolean' }); + if (!parsed.valid) throw new Error('Invalid test schema'); + await expect(runner.runInvocation({ + invocationId: 'b'.repeat(32), seedId: 'c'.repeat(32), payload: 'print(True)', + schema: parsed.schema, admissionId: 'd'.repeat(32), executorKind: 'script', deadlineMs: 1000, + })).resolves.toEqual({ + status: 'completed', timedOut: false, exitCode: 0, rawResult: 'true', + }); + expect(Object.keys(client.invoke.mock.calls[0][0]).sort()).toEqual([ + 'admissionId', 'entryId', 'executorKind', 'invocationId', 'payload', 'schema', 'schemaHash', 'seedId', + ]); + expect(client.settle).toHaveBeenCalledWith({ + entryId: 'script', invocationId: 'b'.repeat(32), resultDigest: digest, + }); + }, + ); + + it('preserves local Docker routing unless trusted configuration selects host transport', () => { + expect(usesHostExecutor({ executorBackend: 'docker' })).toBe(false); + expect(usesHostExecutor({ executorBackend: 'gvisor' })).toBe(false); + expect(usesHostExecutor({ executorBackend: 'cloud-hypervisor' })).toBe(true); + expect(() => createHostExecutorRunner({ executorBackend: 'nvx', entryId: 'script' })) + .toThrow('Invalid trusted host executor configuration'); + }); + + it('does not enable NVX through broker environment selection', () => { + const environment = process.env; + process.env = { + ...environment, AWF_ENCLAVE_BACKEND: 'nvx', AWF_ENCLAVE_AGENT_BACKEND: 'nvx', + }; + try { + expect(() => loadConfig()).toThrow(/AWF_ENCLAVE_BACKEND/); + expect(() => loadAgentConfig({})).toThrow(/AWF_ENCLAVE_AGENT_BACKEND/); + } finally { + process.env = environment; + } + }); +}); diff --git a/src/enclave/host-executor-run-state.ts b/src/enclave/host-executor-run-state.ts new file mode 100644 index 000000000..529efb229 --- /dev/null +++ b/src/enclave/host-executor-run-state.ts @@ -0,0 +1,68 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { WrapperConfig } from '../types'; +import { PRIVATE_REPOSITORY_SEED_MAP_VERSION, type PrivateRepositorySeedMap } from '../bounded-execution'; +import { normalizePrivateRepositoryKey } from '../bounded-execution/repository-staging'; +import type { HostExecutorRunState } from './host-executor-server'; +import { deriveEnclaveSeedId, readEnclaveRunId, type EnclavePaths } from './paths'; +import { validateEnclavesStructure } from './preflight'; +import { validateRunSandboxBackend } from './run-backend'; + +/** Derives static authority from the validated host catalog, never from invocation data. */ +export function deriveHostExecutorRunState( + config: WrapperConfig, + paths: EnclavePaths, + label = 'Host executor', +): HostExecutorRunState { + if (!config.enclaves?.enabled) throw new Error(`Invalid trusted ${label} configuration: no executor`); + const errors = [ + ...validateRunSandboxBackend(config.containerRuntime, config.enclaves), + ...validateEnclavesStructure(config, { requireDelegationHandoff: false }), + ]; + if (errors.length) { + throw new Error(`Invalid trusted ${label} configuration: ${errors.join('; ')}`); + } + const runId = readEnclaveRunId(paths); + if (!runId) throw new Error(`${label}s require a staged trusted run ID`); + const seedMap = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')) as PrivateRepositorySeedMap; + if (seedMap.version !== PRIVATE_REPOSITORY_SEED_MAP_VERSION + || seedMap.runId !== runId || !Array.isArray(seedMap.seeds)) { + throw new Error(`${label} seed map does not match the trusted run`); + } + const catalog = new Map(config.enclaves.privateRepos.map((repo) => [ + normalizePrivateRepositoryKey(repo.repo), repo, + ])); + const seeds = new Map(); + for (const seed of seedMap.seeds) { + const repo = catalog.get(seed.repo); + if (!repo || seeds.has(seed.repo) || seed.sensitivity !== repo.sensitivity + || seed.seedId !== deriveEnclaveSeedId(runId, seed.repo)) { + throw new Error(`${label} seed map differs from the validated catalog`); + } + const stat = fs.lstatSync(path.join(paths.seedsDir, seed.seedId)); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`${label} seeds must be staged real directories`); + } + seeds.set(seed.repo, seed.seedId); + } + if (seeds.size !== catalog.size) throw new Error(`${label} seed catalog is incomplete`); + return { + runId, + seedsDir: paths.seedsDir, + invocationsDir: path.join(path.dirname(paths.hostExecutorJournalDir), 'host-invocations', runId), + journalDir: paths.hostExecutorJournalDir, + entries: Object.entries(config.enclaves.executors) + .filter(([, entry]) => entry.enabled) + .map(([entryId, entry]) => ({ + entryId, + executorKind: entryId as 'script' | 'agent', + timeoutMs: entry.timeout * 1000, + staticSeedIds: config.enclaves!.privateRepos.map((repo) => { + const seed = seeds.get(normalizePrivateRepositoryKey(repo.repo)); + if (!seed) throw new Error(`${label} entry references an unstaged repository`); + return seed; + }), + dynamicAgents: false, + })), + }; +} diff --git a/src/enclave/host-service.test.ts b/src/enclave/host-service.test.ts new file mode 100644 index 000000000..05ad76945 --- /dev/null +++ b/src/enclave/host-service.test.ts @@ -0,0 +1,42 @@ +import { startHostExecutorService } from './host-service'; +import { startHostExecutorServer } from './host-executor-server'; + +jest.mock('./host-executor-server'); +const startServer = jest.mocked(startHostExecutorServer); + +describe('backend-neutral authenticated host service', () => { + const runState = { + runId: 'a'.repeat(32), seedsDir: '/private/seeds', invocationsDir: '/private/invocations', entries: [], + }; + beforeEach(() => jest.resetAllMocks()); + + it('allows an atomic backend without startInstance or readiness methods', async () => { + const backend = { execute: jest.fn() }; + const server = { close: jest.fn() }; + startServer.mockResolvedValue(server as never); + await expect(startHostExecutorService({ + runtimeDir: '/private/service', runState, createBackend: async () => backend, + startupFailureMessage: 'service unavailable', + })).resolves.toBe(server); + expect(startServer).toHaveBeenCalledWith({ runtimeDir: '/private/service', runState, backend }); + }); + + it('completes backend preflight and recovery before publishing the listener', async () => { + await expect(startHostExecutorService({ + runtimeDir: '/private/service', runState, + createBackend: async () => { throw new Error('recovery pending'); }, + startupFailureMessage: 'service unavailable', + })).rejects.toThrow('recovery pending'); + expect(startServer).not.toHaveBeenCalled(); + }); + + it('closes a container-owning backend on failed startup without exposing private errors', async () => { + const backend = { execute: jest.fn(), close: jest.fn().mockRejectedValue(new Error('private cleanup')) }; + startServer.mockRejectedValue(new Error('private listener')); + await expect(startHostExecutorService({ + runtimeDir: '/private/service', runState, createBackend: async () => backend, + startupFailureMessage: 'service unavailable', + })).rejects.toThrow('service unavailable'); + expect(backend.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/enclave/host-service.ts b/src/enclave/host-service.ts new file mode 100644 index 000000000..37ca69d2e --- /dev/null +++ b/src/enclave/host-service.ts @@ -0,0 +1,36 @@ +import { + startHostExecutorServer, + type HostEnclaveExecutorBackend, + type HostExecutorRunState, + type HostExecutorServer, +} from './host-executor-server'; + +export interface HostExecutorServiceOptions { + readonly runtimeDir: string; + readonly runState: HostExecutorRunState; + /** Includes backend-specific recovery and acceptance checks before listener publication. */ + readonly createBackend: () => Promise; + readonly onListening?: () => void; + readonly startupFailureMessage: string; +} + +/** + * Readiness here means authenticated host-service readiness, not a persistent + * workload instance. Backends may execute atomically or own a container lifecycle. + */ +export async function startHostExecutorService( + options: HostExecutorServiceOptions, +): Promise { + const backend = await options.createBackend(); + try { + options.onListening?.(); + return await startHostExecutorServer({ + runtimeDir: options.runtimeDir, + runState: options.runState, + backend, + }); + } catch { + await backend.close?.().catch(() => undefined); + throw new Error(options.startupFailureMessage); + } +} diff --git a/src/enclave/invocation-result.test.ts b/src/enclave/invocation-result.test.ts new file mode 100644 index 000000000..89d63f16b --- /dev/null +++ b/src/enclave/invocation-result.test.ts @@ -0,0 +1,53 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { readBoundedEnclaveResult } from './invocation-result'; +import { HOST_EXECUTOR_MAX_RESULT_BYTES } from './host-executor-protocol'; +import type { FiniteSchemaNode } from '../bounded-execution/finite-schema'; + +describe('backend-neutral bounded invocation results', () => { + let directory: string; + let output: string; + const schema: FiniteSchemaNode = { type: 'boolean' }; + + beforeEach(async () => { + directory = await fs.mkdtemp(path.join(process.cwd(), '.awf-shared-result-')); + output = path.join(directory, 'out'); + }); + + afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }); + }); + + it('validates and canonicalizes a bounded JSON value', async () => { + await fs.writeFile(output, ' true \n'); + await expect(readBoundedEnclaveResult(output, schema, 7)).resolves.toBe('true'); + await fs.writeFile(output, 'false'); + await expect(readBoundedEnclaveResult(output, schema, 5)).resolves.toBe('false'); + }); + + it.each(['', 'null', '"true"', 'true false', 'x'.repeat(65)])('rejects invalid or oversized results %j', async (value) => { + await fs.writeFile(output, value); + await expect(readBoundedEnclaveResult(output, schema, 64)).resolves.toBeUndefined(); + }); + + it('rejects malformed UTF-8 instead of accepting a replacement character', async () => { + await fs.writeFile(output, Buffer.from([0x22, 0xff, 0x22])); + await expect(readBoundedEnclaveResult(output, { type: 'string' }, 64)) + .resolves.toBeUndefined(); + }); + + it('rejects missing files, directories and symlinks', async () => { + await expect(readBoundedEnclaveResult(output, schema, 64)).resolves.toBeUndefined(); + await expect(readBoundedEnclaveResult(directory, schema, 64)).resolves.toBeUndefined(); + const target = path.join(directory, 'target'); + await fs.writeFile(target, 'true'); + await fs.symlink(target, output); + await expect(readBoundedEnclaveResult(output, schema, 64)).resolves.toBeUndefined(); + }); + + it.each([0, -1, 1.5, NaN, Infinity, HOST_EXECUTOR_MAX_RESULT_BYTES + 1])( + 'rejects invalid trusted result bounds without allocating: %s', async (bound) => { + await fs.writeFile(output, 'true'); + await expect(readBoundedEnclaveResult(output, schema, bound)).resolves.toBeUndefined(); + }); +}); diff --git a/src/enclave/invocation-result.ts b/src/enclave/invocation-result.ts new file mode 100644 index 000000000..a0ff2c222 --- /dev/null +++ b/src/enclave/invocation-result.ts @@ -0,0 +1,37 @@ +import { constants, promises as fs } from 'fs'; +import { TextDecoder } from 'util'; +import { + canonicalizeSchemaValue, + validateValueAgainstSchema, + type FiniteSchemaNode, +} from '../bounded-execution/finite-schema'; +import { HOST_EXECUTOR_MAX_RESULT_BYTES } from './host-executor-protocol'; + +export async function readBoundedEnclaveResult( + outputPath: string, + schema: FiniteSchemaNode, + maxBytes: number, +): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > HOST_EXECUTOR_MAX_RESULT_BYTES) { + return undefined; + } + let handle; + try { + handle = await fs.open(outputPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size < 1 || stat.size > maxBytes) return undefined; + const bytes = Buffer.alloc(maxBytes + 1); + const { bytesRead } = await handle.read(bytes, 0, maxBytes + 1, 0); + if (bytesRead > maxBytes) return undefined; + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, bytesRead)); + const parsed: unknown = JSON.parse(text); + if (!validateValueAgainstSchema(schema, parsed)) return undefined; + const canonical = canonicalizeSchemaValue(schema, parsed); + if (Buffer.byteLength(canonical, 'utf8') > maxBytes) return undefined; + return canonical; + } catch { + return undefined; + } finally { + await handle?.close(); + } +} diff --git a/src/enclave/invocation-staging.test.ts b/src/enclave/invocation-staging.test.ts new file mode 100644 index 000000000..1e864c522 --- /dev/null +++ b/src/enclave/invocation-staging.test.ts @@ -0,0 +1,85 @@ +import * as path from 'path'; +import { stageInvocationInputs, invocationFilePath, type InvocationInputPlan } from './invocation-staging'; +import { ENCLAVE_RESOURCE_POLICIES, type HostExecutorAgentPolicy } from './execution-policy'; + +const plan: InvocationInputPlan = { + invocationHostDir: '/trusted/invocation', + payload: 'trusted input', + schema: { type: 'boolean' }, +}; +const policy: HostExecutorAgentPolicy = { model: 'trusted', profile: 'openai', maxOutputBytes: 4096 }; + +function dependencies() { + return { + writeFile: jest.fn().mockResolvedValue(undefined), + chmod: jest.fn().mockResolvedValue(undefined), + chown: jest.fn().mockResolvedValue(undefined), + }; +} + +describe('backend-neutral invocation input staging', () => { + it('stages only the script input with fixed ownership and exclusive creation', async () => { + const deps = dependencies(); + await stageInvocationInputs(plan, 'script', undefined, deps); + expect(deps.writeFile).toHaveBeenCalledTimes(1); + const target = path.join(plan.invocationHostDir, 'request', 'query-script.py'); + expect(deps.writeFile).toHaveBeenCalledWith(target, plan.payload, { + encoding: 'utf8', mode: 0o400, flag: 'wx', + }); + expect(deps.chown).toHaveBeenCalledWith(target, ENCLAVE_RESOURCE_POLICIES.script.uid, + ENCLAVE_RESOURCE_POLICIES.script.gid); + expect(deps.chmod).toHaveBeenCalledWith(target, 0o400); + }); + + it('stages an agent task, schema, writable session and scoped credential files', async () => { + const deps = dependencies(); + await stageInvocationInputs(plan, 'agent', { + ...policy, githubAgentId: 'agent-id', githubBearer: 'invocation-scoped-test-bearer', + }, deps); + expect(deps.writeFile.mock.calls.map(([target, contents, options]) => [ + path.relative(plan.invocationHostDir, target), contents, options.mode, options.flag, + ])).toEqual([ + ['request/task.txt', plan.payload, 0o400, 'wx'], + ['request/schema.json', JSON.stringify(plan.schema), 0o400, 'wx'], + ['runtime/session.jsonl', '', 0o600, 'wx'], + ['session-handoff/github-agent-id', 'agent-id\n', 0o400, 'wx'], + ['session-handoff/github-bearer', 'invocation-scoped-test-bearer\n', 0o400, 'wx'], + ]); + expect(deps.chmod).toHaveBeenCalledTimes(5); + expect(deps.chown).toHaveBeenCalledTimes(5); + }); + + it('rejects missing agent policy before writing inputs', async () => { + const deps = dependencies(); + await expect(stageInvocationInputs(plan, 'agent', undefined, deps)).rejects.toThrow(/Trusted agent policy/); + expect(deps.writeFile).not.toHaveBeenCalled(); + }); + + it('rejects host GitHub credentials before writing any invocation inputs', async () => { + const previous = process.env.GITHUB_PAT; + const token = 'host-token-for-input-staging-test'; + process.env.GITHUB_PAT = token; + const deps = dependencies(); + try { + await expect(stageInvocationInputs(plan, 'agent', { + ...policy, githubAgentId: 'agent-id', githubBearer: token, + }, deps)).rejects.toThrow(/host GitHub token/); + expect(deps.writeFile).not.toHaveBeenCalled(); + } finally { + if (previous === undefined) delete process.env.GITHUB_PAT; + else process.env.GITHUB_PAT = previous; + } + }); + + it('does not continue after an exclusive-write failure', async () => { + const deps = dependencies(); + deps.writeFile.mockRejectedValue(new Error('EEXIST')); + await expect(stageInvocationInputs(plan, 'agent', policy, deps)).rejects.toThrow('EEXIST'); + expect(deps.chown).not.toHaveBeenCalled(); + expect(deps.chmod).not.toHaveBeenCalled(); + }); + + it('rejects paths escaping their designated input directory', () => { + expect(() => invocationFilePath('/trusted/input', '../outside')).toThrow(/artifact path/); + }); +}); diff --git a/src/enclave/invocation-staging.ts b/src/enclave/invocation-staging.ts new file mode 100644 index 000000000..cc3a74790 --- /dev/null +++ b/src/enclave/invocation-staging.ts @@ -0,0 +1,79 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import type { FiniteSchemaNode } from '../bounded-execution/finite-schema'; +import { + assertNoHostGithubTokenValue, + getGithubTokenEnvValues, +} from '../services/agent-environment/github-token-isolation'; +import { + ENCLAVE_RESOURCE_POLICIES, + type EnclaveExecutionRole, + type HostExecutorAgentPolicy, +} from './execution-policy'; + +export interface InvocationInputPlan { + readonly invocationHostDir: string; + readonly payload: string; + readonly schema: FiniteSchemaNode; +} + +export interface InvocationStagingDependencies { + readonly writeFile: typeof fs.writeFile; + readonly chown: typeof fs.chown; + readonly chmod: typeof fs.chmod; +} + +export function invocationFilePath(directory: string, name: string): string { + const result = path.join(directory, name); + if (path.dirname(result) !== directory) throw new Error('Invalid enclave artifact path'); + return result; +} + +export async function writePrivateInvocationFile( + filePathValue: string, + contents: string, + uid: number, + gid: number, + mode: number, + dependencies: InvocationStagingDependencies, +): Promise { + await dependencies.writeFile(filePathValue, contents, { encoding: 'utf8', mode, flag: 'wx' }); + await dependencies.chown(filePathValue, uid, gid); + await dependencies.chmod(filePathValue, mode); +} + +export async function stageInvocationInputs( + plan: InvocationInputPlan, + role: EnclaveExecutionRole, + policy: HostExecutorAgentPolicy | undefined, + dependencies: InvocationStagingDependencies, + credentialContext = 'enclave GitHub bearer file', +): Promise { + if (role === 'agent' && policy?.githubBearer) { + assertNoHostGithubTokenValue( + policy.githubBearer, + getGithubTokenEnvValues(), + credentialContext, + ); + } + const resourceProfile = ENCLAVE_RESOURCE_POLICIES[role]; + const requestDir = invocationFilePath(plan.invocationHostDir, 'request'); + const write = (directory: string, name: string, contents: string, mode = 0o400) => + writePrivateInvocationFile( + invocationFilePath(directory, name), contents, + resourceProfile.uid, resourceProfile.gid, mode, dependencies, + ); + if (role === 'script') { + await write(requestDir, 'query-script.py', plan.payload); + return; + } + if (!policy) throw new Error('Trusted agent policy is required for an agent enclave'); + await write(requestDir, 'task.txt', plan.payload); + await write(requestDir, 'schema.json', JSON.stringify(plan.schema)); + await write(invocationFilePath(plan.invocationHostDir, 'runtime'), 'session.jsonl', '', 0o600); + if (policy.githubAgentId !== undefined && policy.githubBearer !== undefined) { + const handoffDir = invocationFilePath(plan.invocationHostDir, 'session-handoff'); + await write(handoffDir, 'github-agent-id', `${policy.githubAgentId}\n`); + await write(handoffDir, 'github-bearer', `${policy.githubBearer}\n`); + } +}