diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 4cd0dd999..519bdc7f5 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -75,6 +75,7 @@ following top-level properties. All are OPTIONAL: | `apiProxy` | object | API proxy sidecar configuration | | `security` | object | Security and isolation settings | | `container` | object | Container and Docker settings | +| `firecracker` | object | Firecracker v1.16.1 control-plane preview settings | | `chroot` | object | Chroot execution overrides for split-filesystem ARC/DinD runners | | `dind` | object | Bootstrap helpers for ARC/DinD split runner/daemon filesystems | | `runner` | object | Runner topology declaration (standard vs. ARC/DinD) | @@ -188,7 +189,19 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `container.dockerHostPathPrefix` → `--docker-host-path-prefix` - `container.runnerToolCachePath` → *(config-only; checked first for optional read-only runner tool cache mount, before `RUNNER_TOOL_CACHE` and `/home/runner/work/_tool` auto-detection)* - `container.mounts[]` → `-v, --mount` *(repeatable; each array entry maps to one Docker volume mount in `/host_path:/container_path[:ro|rw]` format (both paths must be absolute; host path must exist); in chroot mode, container paths are automatically prefixed with `/host`)* -- `container.containerRuntime` → `--container-runtime` *(user-facing runtime name: `"gvisor"` for OCI runtime in compose, `"sbx"` for Docker sbx microVM. For gvisor: translates to `"runsc"`, injects `extra_hosts` for DNS workaround. For sbx: agent runs in a hypervisor-isolated microVM, infra stays in compose, sbx proxy chains through AWF's Squid.)* +- `container.containerRuntime` → `--container-runtime` *(user-facing runtime name: `"gvisor"` for OCI runtime in compose, `"sbx"` for Docker sbx microVM, or `"firecracker"` for the fail-closed Firecracker v1.16.1 control-plane preview. For gvisor: translates to `"runsc"`, injects `extra_hosts` for DNS workaround. For sbx: agent runs in a hypervisor-isolated microVM, infra stays in compose, sbx proxy chains through AWF's Squid.)* +- `firecracker.previewEnabled` → `--firecracker-preview` +- `firecracker.firecrackerBinary` → `--firecracker-binary` +- `firecracker.jailerBinary` → `--firecracker-jailer-binary` +- `firecracker.kernelPath` → `--firecracker-kernel` +- `firecracker.rootfsPath` → `--firecracker-rootfs` +- `firecracker.vcpuCount` → `--firecracker-vcpus` +- `firecracker.memoryMib` → `--firecracker-memory-mib` +- `firecracker.apiTimeoutMs` → `--firecracker-api-timeout-ms` +- `firecracker.sha256.firecracker` → `--firecracker-binary-sha256` +- `firecracker.sha256.jailer` → `--firecracker-jailer-sha256` +- `firecracker.sha256.kernel` → `--firecracker-kernel-sha256` +- `firecracker.sha256.rootfs` → `--firecracker-rootfs-sha256` - `chroot.binariesSourcePath` → *(config-only; mounts a runner-side binaries directory at `/tmp/awf-runner-bin` inside chroot mode and prepends it to `PATH`)* - `chroot.identity.home` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_HOME` and applied after chroot pivot)* - `chroot.identity.user` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_USER` and applied to `USER`/`LOGNAME` after chroot pivot)* @@ -248,6 +261,14 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). When `container.dockerHostPathPrefix` points at a daemon-visible shared `/tmp` path, the implementation stages the invoking CLI binary together with `/etc/passwd`, `/etc/group`, and the generated chroot `/etc/hosts` under that shared path so chroot mode can bootstrap on split-filesystem ARC/DinD hosts. +The `firecracker` surface is a control-plane preview pinned to Firecracker +v1.16.1 on Linux/KVM (`x86_64` or `aarch64`). AWF MUST launch Firecracker +through the matching jailer, reject unsafe or mismatched artifacts, and MUST +NOT fall back to another runtime. Networking, workspace images, enclave +executors, and guest workload execution are intentionally unavailable in this +preview; selecting `firecracker` therefore fails closed before running the +requested command. + When DinD is detected, AWF preserves the detected `DOCKER_HOST` value for the agent environment (including MCP servers) so DinD-aware tooling can reach the correct daemon without manual workflow env overrides. The following CLI flag has no config-file equivalent by design: diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 616b410a6..9c084ab4d 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -649,9 +649,75 @@ "type": "string", "enum": [ "gvisor", - "sbx" + "sbx", + "firecracker" ], - "description": "Container runtime for the agent container. \"gvisor\" runs the agent under gVisor's runsc runtime (OCI runtime, compose-based). \"sbx\" runs the agent inside a Docker sbx microVM with hypervisor isolation; infrastructure containers (squid-proxy, api-proxy) stay in Docker Compose on the host and the sbx proxy chains upstream through AWF's Squid for domain filtering. Only the agent uses the custom runtime; infrastructure containers always use the default runc runtime." + "description": "Runtime for the primary agent. \"gvisor\" uses runsc in Docker Compose. \"sbx\" uses a Docker sbx microVM. \"firecracker\" selects the fail-closed Firecracker v1.16.1 control-plane preview; networking and guest workload execution are not implemented. Infrastructure containers always use the default runc runtime." + } + } + }, + "firecracker": { + "type": "object", + "description": "Firecracker v1.16.1 control-plane preview configuration. Selecting this runtime never falls back to Docker and cannot execute workloads until networking and guest execution are implemented.", + "additionalProperties": false, + "properties": { + "previewEnabled": { + "type": "boolean", + "default": false, + "description": "Explicitly acknowledge the incomplete Firecracker control-plane preview. This does not enable workload execution." + }, + "firecrackerBinary": { + "type": "string", + "description": "Absolute path to the Firecracker v1.16.1 binary. Defaults to /usr/local/bin/firecracker." + }, + "jailerBinary": { + "type": "string", + "description": "Absolute path to the matching v1.16.1 jailer binary. Defaults to /usr/local/bin/jailer." + }, + "kernelPath": { + "type": "string", + "description": "Absolute path to the trusted guest Linux kernel image." + }, + "rootfsPath": { + "type": "string", + "description": "Absolute path to the trusted guest root filesystem image." + }, + "vcpuCount": { + "type": "integer", + "minimum": 1, + "default": 2, + "description": "Number of guest virtual CPUs." + }, + "memoryMib": { + "type": "integer", + "minimum": 1, + "default": 512, + "description": "Guest memory in MiB." + }, + "apiTimeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "Bounded timeout in milliseconds for Firecracker API socket readiness and requests." + }, + "sha256": { + "type": "object", + "description": "Optional pinned SHA-256 digests for trusted Firecracker artifacts.", + "additionalProperties": false, + "properties": { + "firecracker": { + "$ref": "#/$defs/sha256Digest" + }, + "jailer": { + "$ref": "#/$defs/sha256Digest" + }, + "kernel": { + "$ref": "#/$defs/sha256Digest" + }, + "rootfs": { + "$ref": "#/$defs/sha256Digest" + } + } } } }, @@ -1139,6 +1205,11 @@ } }, "$defs": { + "sha256Digest": { + "type": "string", + "pattern": "^[A-Fa-f0-9]{64}$", + "description": "A SHA-256 digest encoded as exactly 64 hexadecimal characters." + }, "providerTarget": { "type": "object", "description": "API provider target override.", diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 616b410a6..9c084ab4d 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -649,9 +649,75 @@ "type": "string", "enum": [ "gvisor", - "sbx" + "sbx", + "firecracker" ], - "description": "Container runtime for the agent container. \"gvisor\" runs the agent under gVisor's runsc runtime (OCI runtime, compose-based). \"sbx\" runs the agent inside a Docker sbx microVM with hypervisor isolation; infrastructure containers (squid-proxy, api-proxy) stay in Docker Compose on the host and the sbx proxy chains upstream through AWF's Squid for domain filtering. Only the agent uses the custom runtime; infrastructure containers always use the default runc runtime." + "description": "Runtime for the primary agent. \"gvisor\" uses runsc in Docker Compose. \"sbx\" uses a Docker sbx microVM. \"firecracker\" selects the fail-closed Firecracker v1.16.1 control-plane preview; networking and guest workload execution are not implemented. Infrastructure containers always use the default runc runtime." + } + } + }, + "firecracker": { + "type": "object", + "description": "Firecracker v1.16.1 control-plane preview configuration. Selecting this runtime never falls back to Docker and cannot execute workloads until networking and guest execution are implemented.", + "additionalProperties": false, + "properties": { + "previewEnabled": { + "type": "boolean", + "default": false, + "description": "Explicitly acknowledge the incomplete Firecracker control-plane preview. This does not enable workload execution." + }, + "firecrackerBinary": { + "type": "string", + "description": "Absolute path to the Firecracker v1.16.1 binary. Defaults to /usr/local/bin/firecracker." + }, + "jailerBinary": { + "type": "string", + "description": "Absolute path to the matching v1.16.1 jailer binary. Defaults to /usr/local/bin/jailer." + }, + "kernelPath": { + "type": "string", + "description": "Absolute path to the trusted guest Linux kernel image." + }, + "rootfsPath": { + "type": "string", + "description": "Absolute path to the trusted guest root filesystem image." + }, + "vcpuCount": { + "type": "integer", + "minimum": 1, + "default": 2, + "description": "Number of guest virtual CPUs." + }, + "memoryMib": { + "type": "integer", + "minimum": 1, + "default": 512, + "description": "Guest memory in MiB." + }, + "apiTimeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "Bounded timeout in milliseconds for Firecracker API socket readiness and requests." + }, + "sha256": { + "type": "object", + "description": "Optional pinned SHA-256 digests for trusted Firecracker artifacts.", + "additionalProperties": false, + "properties": { + "firecracker": { + "$ref": "#/$defs/sha256Digest" + }, + "jailer": { + "$ref": "#/$defs/sha256Digest" + }, + "kernel": { + "$ref": "#/$defs/sha256Digest" + }, + "rootfs": { + "$ref": "#/$defs/sha256Digest" + } + } } } }, @@ -1139,6 +1205,11 @@ } }, "$defs": { + "sha256Digest": { + "type": "string", + "pattern": "^[A-Fa-f0-9]{64}$", + "description": "A SHA-256 digest encoded as exactly 64 hexadecimal characters." + }, "providerTarget": { "type": "object", "description": "API provider target override.", diff --git a/src/cli-options.ts b/src/cli-options.ts index 64d6fd937..2fecf7926 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -10,6 +10,7 @@ const optionGroupHeaders: Record = { 'config': 'Configuration:', 'allow-domains': 'Domain Filtering:', 'build-local': 'Image Management:', + 'firecracker-preview': 'Firecracker Preview:', 'env': 'Container Configuration:', 'dns-servers': 'Network & Security:', 'upstream-proxy': 'Network & Security:', @@ -174,8 +175,26 @@ program 'Container runtime for the agent container.\n' + ' "gvisor" — OCI runtime via Docker Compose (translates to runsc).\n' + ' "sbx" — Docker sbx microVM with hypervisor isolation.\n' + + ' "firecracker" — preview Firecracker v1.16.1 control plane (workload execution unavailable).\n' + ' Unknown values are passed through as raw Docker runtime names.' ) + .option( + '--firecracker-preview', + 'Acknowledge the incomplete Firecracker v1.16.1 preview control plane.\n' + + ' Networking and guest workload execution remain unavailable.', + false + ) + .option('--firecracker-binary ', 'Path to the Firecracker v1.16.1 binary.') + .option('--firecracker-jailer-binary ', 'Path to the matching Firecracker v1.16.1 jailer binary.') + .option('--firecracker-kernel ', 'Path to the guest Linux kernel image.') + .option('--firecracker-rootfs ', 'Path to the guest root filesystem image.') + .option('--firecracker-vcpus ', 'Guest virtual CPU count (default: 2).') + .option('--firecracker-memory-mib ', 'Guest memory in MiB (default: 512).') + .option('--firecracker-api-timeout-ms ', 'Bounded API socket readiness timeout in milliseconds (default: 5000).') + .option('--firecracker-binary-sha256 ', 'Expected SHA-256 digest of the Firecracker binary.') + .option('--firecracker-jailer-sha256 ', 'Expected SHA-256 digest of the jailer binary.') + .option('--firecracker-kernel-sha256 ', 'Expected SHA-256 digest of the guest kernel.') + .option('--firecracker-rootfs-sha256 ', 'Expected SHA-256 digest of the guest rootfs.') // -- Container Configuration -- .option( diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index f9f8c51de..9338e3778 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -3,6 +3,13 @@ import type { AwfFileConfig } from '../config-file'; import { resolveApiCredentials } from './resolve-credentials'; import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; import { logger } from '../logger'; +import { + FIRECRACKER_DEFAULT_API_TIMEOUT_MS, + FIRECRACKER_DEFAULT_BINARY, + FIRECRACKER_DEFAULT_JAILER_BINARY, + FIRECRACKER_DEFAULT_MEMORY_MIB, + FIRECRACKER_DEFAULT_VCPU_COUNT, +} from '../types/runtime-options'; /** * Resolves the effective `legacySecurity` value from CLI options. @@ -117,6 +124,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { const chrootIdentity = buildChrootIdentity(options); const dind = buildDindConfig(options); + const firecracker = buildFirecrackerConfig(options); const apiCredentials = resolveApiCredentials(options, { resolvedCopilotApiTarget, resolvedCopilotApiBasePath, @@ -218,12 +226,83 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { chrootBinariesSourcePath: options.chrootBinariesSourcePath as string | undefined, chrootIdentity, dind, + firecracker, enclaves: normalizeEnclavesConfig( options.enclaves as AwfFileConfig['enclaves'] | undefined, ), }; } +function buildFirecrackerConfig( + options: Record, +): WrapperConfig['firecracker'] { + const selected = options.containerRuntime === 'firecracker'; + const configured = options.firecrackerPreview === true + || [ + 'firecrackerBinary', + 'firecrackerJailerBinary', + 'firecrackerKernel', + 'firecrackerRootfs', + 'firecrackerVcpus', + 'firecrackerMemoryMib', + 'firecrackerApiTimeoutMs', + 'firecrackerBinarySha256', + 'firecrackerJailerSha256', + 'firecrackerKernelSha256', + 'firecrackerRootfsSha256', + ].some((key) => options[key] !== undefined); + if (!selected && !configured) return undefined; + + const sha256 = { + firecracker: options.firecrackerBinarySha256 as string | undefined, + jailer: options.firecrackerJailerSha256 as string | undefined, + kernel: options.firecrackerKernelSha256 as string | undefined, + rootfs: options.firecrackerRootfsSha256 as string | undefined, + }; + + return { + previewEnabled: options.firecrackerPreview === true, + firecrackerBinary: + (options.firecrackerBinary as string | undefined) ?? FIRECRACKER_DEFAULT_BINARY, + jailerBinary: + (options.firecrackerJailerBinary as string | undefined) ?? + FIRECRACKER_DEFAULT_JAILER_BINARY, + kernelPath: options.firecrackerKernel as string | undefined, + rootfsPath: options.firecrackerRootfs as string | undefined, + vcpuCount: parseFirecrackerPositiveInteger( + options.firecrackerVcpus, + '--firecracker-vcpus', + FIRECRACKER_DEFAULT_VCPU_COUNT, + ), + memoryMib: parseFirecrackerPositiveInteger( + options.firecrackerMemoryMib, + '--firecracker-memory-mib', + FIRECRACKER_DEFAULT_MEMORY_MIB, + ), + apiTimeoutMs: parseFirecrackerPositiveInteger( + options.firecrackerApiTimeoutMs, + '--firecracker-api-timeout-ms', + FIRECRACKER_DEFAULT_API_TIMEOUT_MS, + ), + sha256: Object.values(sha256).some((value) => value !== undefined) + ? sha256 + : undefined, + }; +} + +function parseFirecrackerPositiveInteger( + value: unknown, + optionName: string, + defaultValue: number, +): number { + if (value === undefined) return defaultValue; + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${optionName} must be a positive integer`); + } + return parsed; +} + function buildChrootIdentity( options: Record ): WrapperConfig['chrootIdentity'] { diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index 84613e183..010dd99c1 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -415,6 +415,28 @@ describe('createMainAction', () => { expect(processExitSpy).toHaveBeenCalledWith(1); }); }); + + describe('when external runtime preflight fails', () => { + it('aborts before entering the main workflow', async () => { + mockedExternalRuntimeResolver.resolveExternalRuntimeBackend.mockImplementationOnce(() => ({ + runtime: 'sbx', + preflight: jest.fn().mockRejectedValue(new Error('preflight failed')), + start: jest.fn(), + exec: jest.fn(), + collectDiagnostics: jest.fn(), + stop: jest.fn(), + })); + + const action = createMainAction(getOptionValueSource); + await expect(action(['echo hi'], {})).rejects.toThrow('process.exit: 1'); + + expect(mockedCliWorkflow.runMainWorkflow).not.toHaveBeenCalled(); + expect(mockedLogger.error).toHaveBeenCalledWith( + 'Fatal error:', + expect.objectContaining({ message: 'preflight failed' }), + ); + }); + }); }); describe('performCleanup with keepContainers=true', () => { diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 29eeee0ba..f3956031c 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -312,6 +312,10 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { }); try { + if (externalRuntimeBackend) { + await externalRuntimeBackend.preflight(); + } + const externalWorkflowDependencies = externalRuntimeBackend ? adaptExternalRuntimeBackend(externalRuntimeBackend) : undefined; diff --git a/src/config-file.ts b/src/config-file.ts index 91ac3b372..7edc5a1a5 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as yaml from 'js-yaml'; import { validateWithSchema } from './schema-validator'; import type { RawEnclavesConfig } from './types/enclave-options'; +import type { FirecrackerArtifactDigests } from './types/runtime-options'; /** @internal Used only by config-file helpers — not part of public API */ // ts-prune-ignore-next @@ -122,6 +123,17 @@ export interface AwfFileConfig { runnerToolCachePath?: string; mounts?: string[]; }; + firecracker?: { + previewEnabled?: boolean; + firecrackerBinary?: string; + jailerBinary?: string; + kernelPath?: string; + rootfsPath?: string; + vcpuCount?: number; + memoryMib?: number; + apiTimeoutMs?: number; + sha256?: FirecrackerArtifactDigests; + }; chroot?: { binariesSourcePath?: string; identity?: { diff --git a/src/config-mapper.ts b/src/config-mapper.ts index 2db05f2ed..360953239 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -114,6 +114,18 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { expect(resolveDockerRuntime('sbx')).toBeUndefined(); }); + it('returns undefined for Firecracker (no OCI runtime)', () => { + expect(resolveDockerRuntime('firecracker')).toBeUndefined(); + }); + it('passes through unknown runtime names unchanged', () => { expect(resolveDockerRuntime('kata')).toBe('kata'); expect(resolveDockerRuntime('custom-runtime')).toBe('custom-runtime'); @@ -32,6 +36,10 @@ describe('container-runtime', () => { expect(runtimeNeedsStaticDns('sbx')).toBe(false); }); + it('returns false for Firecracker', () => { + expect(runtimeNeedsStaticDns('firecracker')).toBe(false); + }); + it('returns false for unknown runtimes', () => { expect(runtimeNeedsStaticDns('kata')).toBe(false); }); @@ -55,6 +63,10 @@ describe('container-runtime', () => { expect(runtimeUsesIptables('sbx')).toBe(false); }); + it('returns false for Firecracker (no host-agent iptables)', () => { + expect(runtimeUsesIptables('firecracker')).toBe(false); + }); + it('returns true for unknown runtimes (share host netns)', () => { expect(runtimeUsesIptables('kata')).toBe(true); }); @@ -81,6 +93,10 @@ describe('container-runtime', () => { expect(runtimeUsesComposeAgent('sbx')).toBe(false); }); + it('returns false for the Firecracker microVM model', () => { + expect(runtimeUsesComposeAgent('firecracker')).toBe(false); + }); + it('returns true for unknown runtimes (assumed compose)', () => { expect(runtimeUsesComposeAgent('kata')).toBe(true); expect(runtimeUsesComposeAgent('runsc')).toBe(true); diff --git a/src/container-runtime.ts b/src/container-runtime.ts index f38c6a66b..10b74f490 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -110,6 +110,12 @@ const RUNTIME_REGISTRY: Readonly> = { needsStaticDns: false, // sbx manages its own DNS usesIptables: false, // microVM manages its own network egress }, + firecracker: { + executionModel: 'microvm', + dockerRuntime: undefined, + needsStaticDns: false, + usesIptables: false, + }, }; /** diff --git a/src/enclave/runtime-preflight.test.ts b/src/enclave/runtime-preflight.test.ts index 824cbac5e..06d56420b 100644 --- a/src/enclave/runtime-preflight.test.ts +++ b/src/enclave/runtime-preflight.test.ts @@ -26,6 +26,7 @@ describe('enclave runtime preflight', () => { ['gvisor', 'gvisor'], ['runsc', 'gvisor'], ['sbx', 'sbx'], + ['firecracker', 'firecracker'], ] as const)('normalizes primary runtime %s to %s', (runtime, expected) => { expect(resolvePrimaryRuntimeBackend(runtime)).toBe(expected); }); @@ -51,6 +52,18 @@ describe('enclave runtime preflight', () => { )).rejects.toThrow(/sbx.*unavailable.*never fall back/); }); + it('recognizes Firecracker but rejects enclave integration without probing fallbacks', async () => { + await expect(assertPrimaryRuntimeAvailable( + 'firecracker', + runtimeAvailable, + dockerAvailable, + sbxAvailable, + )).rejects.toThrow(/control-plane preview.*not implemented.*never fall back/); + expect(runtimeAvailable).not.toHaveBeenCalled(); + expect(dockerAvailable).not.toHaveBeenCalled(); + expect(sbxAvailable).not.toHaveBeenCalled(); + }); + it('fails closed when Docker is unavailable for either executor', async () => { dockerAvailable.mockResolvedValue(false); await expect(assertScriptRuntimeAvailable( diff --git a/src/enclave/runtime-preflight.ts b/src/enclave/runtime-preflight.ts index e722cd472..76d5f7700 100644 --- a/src/enclave/runtime-preflight.ts +++ b/src/enclave/runtime-preflight.ts @@ -14,13 +14,14 @@ import { const RUNSC_RUNTIME = 'runsc'; -export type PrimaryRuntimeBackend = 'docker' | 'gvisor' | 'sbx'; +export type PrimaryRuntimeBackend = 'docker' | 'gvisor' | 'sbx' | 'firecracker'; export function resolvePrimaryRuntimeBackend( containerRuntime: string | undefined, ): PrimaryRuntimeBackend { if (containerRuntime === 'gvisor' || containerRuntime === RUNSC_RUNTIME) return 'gvisor'; if (containerRuntime === 'sbx') return 'sbx'; + if (containerRuntime === 'firecracker') return 'firecracker'; return 'docker'; } @@ -30,6 +31,12 @@ export async function assertPrimaryRuntimeAvailable( queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery, querySbxAvailable: SbxAvailabilityQuery = defaultSbxAvailabilityQuery, ): Promise { + if (containerRuntime === 'firecracker') { + throw new Error( + 'Primary-agent runtime "firecracker" is a control-plane preview; ' + + 'enclave integration is not implemented and enclaves never fall back', + ); + } if (containerRuntime === 'sbx') { if (!(await querySbxAvailable())) { throw new Error('Primary-agent runtime "sbx" is unavailable; enclaves never fall back'); diff --git a/src/external-runtime-backend-resolver.ts b/src/external-runtime-backend-resolver.ts index 8de363a6c..1af56da7a 100644 --- a/src/external-runtime-backend-resolver.ts +++ b/src/external-runtime-backend-resolver.ts @@ -2,6 +2,7 @@ import type { WorkflowDependencies } from './cli-workflow'; import { runtimeUsesComposeAgent } from './container-runtime'; import type { ExternalAgentRuntimeBackend } from './external-runtime-backend'; import { createSbxRuntimeBackend } from './sbx-runtime-backend'; +import { createFirecrackerRuntimeBackend } from './firecracker-runtime-backend'; import type { WrapperConfig } from './types'; export interface ExternalRuntimeBackendFactoryContext { @@ -20,6 +21,8 @@ export type ExternalRuntimeBackendRegistry = Readonly< const EXTERNAL_RUNTIME_BACKENDS: ExternalRuntimeBackendRegistry = { sbx: ({ config, startInfrastructure }) => createSbxRuntimeBackend(config, startInfrastructure), + firecracker: ({ config, startInfrastructure }) => + createFirecrackerRuntimeBackend(config, startInfrastructure), }; /** diff --git a/src/external-runtime-backend.test.ts b/src/external-runtime-backend.test.ts index 78e5db9f4..33731ea8b 100644 --- a/src/external-runtime-backend.test.ts +++ b/src/external-runtime-backend.test.ts @@ -59,6 +59,19 @@ describe('external runtime backend', () => { )).toThrow('No external agent runtime backend is registered for "sbx"'); }); + it('resolves Firecracker to a fail-closed preview backend', async () => { + const config = { + containerRuntime: 'firecracker', + firecracker: { previewEnabled: false }, + } as WrapperConfig; + const backend = resolveExternalRuntimeBackend(config, startInfrastructure); + + expect(backend?.runtime).toBe('firecracker'); + await expect(backend!.start('/tmp/awf', ['github.com'])).rejects.toThrow( + /incomplete control-plane preview/, + ); + expect(startInfrastructure).not.toHaveBeenCalled(); + }); it('adapts start and exec without changing arguments or exit codes', async () => { const backend = createBackend(); const adapted = adaptExternalRuntimeBackend(backend); diff --git a/src/firecracker-runtime-backend.ts b/src/firecracker-runtime-backend.ts new file mode 100644 index 000000000..4173dc777 --- /dev/null +++ b/src/firecracker-runtime-backend.ts @@ -0,0 +1,62 @@ +import type { WorkflowDependencies } from './cli-workflow'; +import type { ExternalAgentRuntimeBackend } from './external-runtime-backend'; +import { runFirecrackerPreflight } from './firecracker/preflight'; +import type { WrapperConfig } from './types'; + +export const FIRECRACKER_INCOMPLETE_CAPABILITY_ERROR = + 'Firecracker runtime workload execution is unavailable in this preview: ' + + 'networking and guest agent/vsock execution are not implemented'; + +export interface FirecrackerRuntimeBackendDependencies { + startInfrastructure: WorkflowDependencies['startContainers']; + preflight: typeof runFirecrackerPreflight; +} + +/** + * Fail-closed backend boundary for the Firecracker control-plane preview. + * + * The manager primitives are intentionally not dispatched by the main workflow + * until networking and guest command execution land in later stack layers. + */ +export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { + readonly runtime = 'firecracker'; + + constructor( + private readonly config: WrapperConfig, + private readonly dependencies: FirecrackerRuntimeBackendDependencies, + ) {} + + async preflight(): Promise { + const firecracker = this.config.firecracker; + if (!firecracker?.previewEnabled) { + throw new Error( + 'Firecracker is an incomplete control-plane preview. ' + + 'Pass --firecracker-preview only for explicit control-plane testing.', + ); + } + await this.dependencies.preflight(firecracker); + } + + readonly start: WorkflowDependencies['startContainers'] = async () => { + await this.preflight(); + throw new Error(FIRECRACKER_INCOMPLETE_CAPABILITY_ERROR); + }; + + readonly exec: WorkflowDependencies['runAgentCommand'] = async () => { + throw new Error(FIRECRACKER_INCOMPLETE_CAPABILITY_ERROR); + }; + + async collectDiagnostics(): Promise {} + + async stop(): Promise {} +} + +export function createFirecrackerRuntimeBackend( + config: WrapperConfig, + startInfrastructure: WorkflowDependencies['startContainers'], +): FirecrackerRuntimeBackend { + return new FirecrackerRuntimeBackend(config, { + startInfrastructure, + preflight: runFirecrackerPreflight, + }); +} diff --git a/src/firecracker/api-client.test.ts b/src/firecracker/api-client.test.ts new file mode 100644 index 000000000..feda29e1c --- /dev/null +++ b/src/firecracker/api-client.test.ts @@ -0,0 +1,132 @@ +import * as http from 'http'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + FirecrackerApiClient, + FirecrackerApiError, +} from './api-client'; + +describe('FirecrackerApiClient', () => { + let directory: string; + let socketPath: string; + let server: http.Server; + + beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-fc-api-')); + socketPath = path.join(directory, 'api.socket'); + }); + + afterEach(async () => { + if (server?.listening) { + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + } + await fs.rm(directory, { recursive: true, force: true }); + }); + + async function listen( + handler: http.RequestListener, + ): Promise { + server = http.createServer(handler); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + } + + it('sends typed JSON requests over the Unix socket', async () => { + const received: Array<{ method?: string; url?: string; body: string }> = []; + await listen((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + received.push({ + method: request.method, + url: request.url, + body: Buffer.concat(chunks).toString('utf8'), + }); + response.writeHead(204).end(); + }); + }); + + const client = new FirecrackerApiClient({ socketPath }); + await client.putMachineConfig({ vcpu_count: 2, mem_size_mib: 512 }); + await client.putDrive({ + drive_id: 'root drive', + path_on_host: '/rootfs', + is_root_device: true, + is_read_only: false, + }); + await client.instanceStart(); + + expect(received).toEqual([ + { + method: 'PUT', + url: '/machine-config', + body: JSON.stringify({ vcpu_count: 2, mem_size_mib: 512 }), + }, + { + method: 'PUT', + url: '/drives/root%20drive', + body: JSON.stringify({ + drive_id: 'root drive', + path_on_host: '/rootfs', + is_root_device: true, + is_read_only: false, + }), + }, + { + method: 'PUT', + url: '/actions', + body: JSON.stringify({ action_type: 'InstanceStart' }), + }, + ]); + }); + + it('returns structured Firecracker API errors', async () => { + await listen((_request, response) => { + response.writeHead(400, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ fault_message: 'invalid machine config' })); + }); + + const client = new FirecrackerApiClient({ socketPath }); + const error = await client.putMachineConfig({ + vcpu_count: 0, + mem_size_mib: 512, + }).catch((caught) => caught); + + expect(error).toBeInstanceOf(FirecrackerApiError); + expect(error).toMatchObject({ + method: 'PUT', + requestPath: '/machine-config', + statusCode: 400, + }); + expect(error.message).toContain('invalid machine config'); + }); + + it('enforces a wall-clock timeout even when the peer keeps sending data', async () => { + await listen((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + const interval = setInterval(() => { + response.write(' '); + }, 5); + response.on('close', () => clearInterval(interval)); + }); + + const client = new FirecrackerApiClient({ socketPath, timeoutMs: 30 }); + await expect(client.getInstanceInfo()).rejects.toThrow(/timed out after 30ms/); + }); + + it('rejects when the response stream errors before completion', async () => { + await listen((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.write('{"id":"vm"'); + response.destroy(new Error('socket closed')); + }); + + const client = new FirecrackerApiClient({ socketPath }); + await expect(client.getInstanceInfo()).rejects.toThrow(); + }); +}); diff --git a/src/firecracker/api-client.ts b/src/firecracker/api-client.ts new file mode 100644 index 000000000..932e92af5 --- /dev/null +++ b/src/firecracker/api-client.ts @@ -0,0 +1,228 @@ +import * as http from 'http'; + +export interface FirecrackerMachineConfig { + vcpu_count: number; + mem_size_mib: number; + smt?: boolean; + track_dirty_pages?: boolean; +} + +export interface FirecrackerBootSource { + kernel_image_path: string; + boot_args?: string; + initrd_path?: string; +} + +export interface FirecrackerRateLimiter { + bandwidth?: { size: number; refill_time: number; one_time_burst?: number }; + ops?: { size: number; refill_time: number; one_time_burst?: number }; +} + +export interface FirecrackerDrive { + drive_id: string; + path_on_host: string; + is_root_device: boolean; + is_read_only: boolean; + cache_type?: 'Unsafe' | 'Writeback'; + io_engine?: 'Sync' | 'Async'; + rate_limiter?: FirecrackerRateLimiter; +} + +export interface FirecrackerVsock { + guest_cid: number; + uds_path: string; +} + +export interface FirecrackerNetworkInterface { + iface_id: string; + host_dev_name: string; + guest_mac?: string; + rx_rate_limiter?: FirecrackerRateLimiter; + tx_rate_limiter?: FirecrackerRateLimiter; +} + +export type FirecrackerActionType = + | 'InstanceStart' + | 'SendCtrlAltDel' + | 'FlushMetrics'; + +export interface FirecrackerInstanceInfo { + id: string; + state: 'Not started' | 'Running' | 'Paused'; + vmm_version: string; + app_name: string; +} + +export type FirecrackerVmState = 'Paused' | 'Resumed'; + +interface FirecrackerErrorBody { + fault_message?: string; +} + +export class FirecrackerApiError extends Error { + constructor( + readonly method: string, + readonly requestPath: string, + readonly statusCode: number, + readonly responseBody: string, + message: string, + ) { + super(message); + this.name = 'FirecrackerApiError'; + } +} + +export interface FirecrackerApiClientOptions { + socketPath: string; + timeoutMs?: number; +} + +/** + * Typed client for Firecracker's REST API over its Unix domain socket. + */ +export class FirecrackerApiClient { + private readonly timeoutMs: number; + + constructor(private readonly options: FirecrackerApiClientOptions) { + this.timeoutMs = options.timeoutMs ?? 5_000; + } + + putMachineConfig(config: FirecrackerMachineConfig): Promise { + return this.request('PUT', '/machine-config', config); + } + + putBootSource(source: FirecrackerBootSource): Promise { + return this.request('PUT', '/boot-source', source); + } + + putDrive(drive: FirecrackerDrive): Promise { + return this.request('PUT', `/drives/${encodeURIComponent(drive.drive_id)}`, drive); + } + + putVsock(vsock: FirecrackerVsock): Promise { + return this.request('PUT', '/vsock', vsock); + } + + putNetworkInterface(networkInterface: FirecrackerNetworkInterface): Promise { + return this.request( + 'PUT', + `/network-interfaces/${encodeURIComponent(networkInterface.iface_id)}`, + networkInterface, + ); + } + + instanceStart(): Promise { + return this.putAction('InstanceStart'); + } + + putAction(actionType: FirecrackerActionType): Promise { + return this.request('PUT', '/actions', { action_type: actionType }); + } + + getInstanceInfo(): Promise { + return this.request('GET', '/'); + } + + patchVmState(state: FirecrackerVmState): Promise { + return this.request('PATCH', '/vm', { state }); + } + + private request( + method: string, + requestPath: string, + payload?: object, + ): Promise { + const body = payload === undefined ? undefined : JSON.stringify(payload); + + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + const error = new Error( + `Firecracker API ${method} ${requestPath} timed out after ${this.timeoutMs}ms`, + ); + rejectOnce(error); + request.destroy(error); + }, this.timeoutMs); + const clearTimer = () => clearTimeout(timer); + const resolveOnce = (value: TResponse) => { + if (settled) return; + settled = true; + clearTimer(); + resolve(value); + }; + const rejectOnce = (error: unknown) => { + if (settled) return; + settled = true; + clearTimer(); + reject(error); + }; + + const request = http.request({ + socketPath: this.options.socketPath, + path: requestPath, + method, + headers: body === undefined + ? undefined + : { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }, (response) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + response.on('error', rejectOnce); + response.on('aborted', () => { + rejectOnce(new Error(`Firecracker API ${method} ${requestPath} response was aborted`)); + }); + response.on('data', (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > 1024 * 1024) { + const error = new Error('Firecracker API response exceeded 1 MiB'); + rejectOnce(error); + request.destroy(error); + return; + } + chunks.push(chunk); + }); + response.on('end', () => { + const responseBody = Buffer.concat(chunks).toString('utf8'); + const statusCode = response.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + let parsed: FirecrackerErrorBody | undefined; + try { + parsed = responseBody ? JSON.parse(responseBody) as FirecrackerErrorBody : undefined; + } catch { + parsed = undefined; + } + const detail = parsed?.fault_message || responseBody || 'empty response'; + rejectOnce(new FirecrackerApiError( + method, + requestPath, + statusCode, + responseBody, + `Firecracker API ${method} ${requestPath} failed with HTTP ${statusCode}: ${detail}`, + )); + return; + } + + if (!responseBody) { + resolveOnce(undefined as TResponse); + return; + } + try { + resolveOnce(JSON.parse(responseBody) as TResponse); + } catch (error) { + rejectOnce(new Error( + `Firecracker API ${method} ${requestPath} returned invalid JSON: ` + + `${error instanceof Error ? error.message : String(error)}`, + )); + } + }); + }); + + request.on('error', rejectOnce); + if (body !== undefined) request.write(body); + request.end(); + }); + } +} diff --git a/src/firecracker/config.test.ts b/src/firecracker/config.test.ts new file mode 100644 index 000000000..90a44a70c --- /dev/null +++ b/src/firecracker/config.test.ts @@ -0,0 +1,123 @@ +import { buildConfig } from '../commands/build-config'; +import { mapAwfFileConfigToCliOptions } from '../config-mapper'; +import { validateAwfFileConfig } from '../config-file'; +import { + FIRECRACKER_DEFAULT_API_TIMEOUT_MS, + FIRECRACKER_DEFAULT_BINARY, + FIRECRACKER_DEFAULT_JAILER_BINARY, + FIRECRACKER_DEFAULT_MEMORY_MIB, + FIRECRACKER_DEFAULT_VCPU_COUNT, +} from '../types/runtime-options'; + +function buildFirecrackerConfig(options: Record) { + return buildConfig({ + options: { + keepContainers: false, + buildLocal: false, + skipPull: false, + imageRegistry: 'registry', + imageTag: 'latest', + envAll: false, + sslBump: false, + enableDind: false, + enableDlp: false, + ...options, + }, + agentCommand: 'echo test', + logLevel: 'info', + allowedDomains: [], + blockedDomains: [], + localhostDetected: false, + additionalEnv: {}, + volumeMounts: undefined, + upstreamProxy: undefined, + dnsServers: [], + dnsOverHttps: undefined, + allowedUrls: undefined, + memoryLimit: undefined, + pidsLimit: undefined, + agentImage: undefined, + modelAliases: undefined, + allowedModels: undefined, + disallowedModels: undefined, + maxEffectiveTokens: undefined, + maxAiCredits: undefined, + effectiveTokenModelMultipliers: undefined, + effectiveTokenDefaultModelMultiplier: undefined, + maxRuns: undefined, + maxPermissionDenied: undefined, + maxCacheMisses: undefined, + resolvedCopilotApiTarget: undefined, + resolvedCopilotApiBasePath: undefined, + dockerHostPathPrefix: undefined, + }).firecracker; +} + +describe('Firecracker configuration', () => { + it('maps the cohesive config-file surface to CLI option semantics', () => { + const digest = 'a'.repeat(64); + const mapped = mapAwfFileConfigToCliOptions({ + firecracker: { + previewEnabled: true, + firecrackerBinary: '/opt/firecracker', + jailerBinary: '/opt/jailer', + kernelPath: '/opt/vmlinux', + rootfsPath: '/opt/rootfs.ext4', + vcpuCount: 4, + memoryMib: 1024, + apiTimeoutMs: 8000, + sha256: { kernel: digest }, + }, + }); + + expect(mapped).toEqual(expect.objectContaining({ + firecrackerPreview: true, + firecrackerBinary: '/opt/firecracker', + firecrackerJailerBinary: '/opt/jailer', + firecrackerKernel: '/opt/vmlinux', + firecrackerRootfs: '/opt/rootfs.ext4', + firecrackerVcpus: 4, + firecrackerMemoryMib: 1024, + firecrackerApiTimeoutMs: 8000, + firecrackerKernelSha256: digest, + })); + }); + + it('applies explicit safe defaults when Firecracker is selected', () => { + expect(buildFirecrackerConfig({ containerRuntime: 'firecracker' })).toEqual({ + previewEnabled: false, + firecrackerBinary: FIRECRACKER_DEFAULT_BINARY, + jailerBinary: FIRECRACKER_DEFAULT_JAILER_BINARY, + kernelPath: undefined, + rootfsPath: undefined, + vcpuCount: FIRECRACKER_DEFAULT_VCPU_COUNT, + memoryMib: FIRECRACKER_DEFAULT_MEMORY_MIB, + apiTimeoutMs: FIRECRACKER_DEFAULT_API_TIMEOUT_MS, + sha256: undefined, + }); + }); + + it('does not populate Firecracker defaults for unrelated runtimes', () => { + expect(buildFirecrackerConfig({ + containerRuntime: 'gvisor', + firecrackerPreview: false, + })).toBeUndefined(); + }); + + it('validates runtime names, positive resources, digests, and unknown keys', () => { + expect(validateAwfFileConfig({ + container: { containerRuntime: 'firecracker' }, + firecracker: { + vcpuCount: 2, + memoryMib: 512, + sha256: { rootfs: '0'.repeat(64) }, + }, + })).toEqual([]); + expect(validateAwfFileConfig({ firecracker: { vcpuCount: 0 } })) + .toContain('config.firecracker.vcpuCount must be a positive integer'); + expect(validateAwfFileConfig({ firecracker: { sha256: { kernel: 'bad' } } })) + .toContain('config.firecracker.sha256.kernel must match pattern "^[A-Fa-f0-9]{64}$"'); + expect(validateAwfFileConfig({ firecracker: { unsupported: true } })) + .toContain('config.firecracker.unsupported is not supported'); + }); +}); diff --git a/src/firecracker/manager.test.ts b/src/firecracker/manager.test.ts new file mode 100644 index 000000000..cd8e59af0 --- /dev/null +++ b/src/firecracker/manager.test.ts @@ -0,0 +1,156 @@ +import type { ExecaChildProcess } from 'execa'; +import type { FirecrackerOptions } from '../types/runtime-options'; +import type { FirecrackerApiClient } from './api-client'; +import { + FirecrackerManager, + createFirecrackerRunPaths, + type FirecrackerManagerDependencies, +} from './manager'; + +function config(overrides: Partial = {}): FirecrackerOptions { + return { + previewEnabled: true, + firecrackerBinary: '/opt/firecracker', + jailerBinary: '/opt/jailer', + kernelPath: '/opt/vmlinux', + rootfsPath: '/opt/rootfs.ext4', + vcpuCount: 2, + memoryMib: 512, + apiTimeoutMs: 1, + ...overrides, + }; +} + +function processMock(): ExecaChildProcess { + const child = Promise.resolve({ exitCode: 0 }) as unknown as ExecaChildProcess; + Object.assign(child, { + exitCode: null, + killed: false, + kill: jest.fn(() => { + Object.assign(child, { exitCode: 0, killed: true }); + return true; + }), + }); + return child; +} + +function dependencies( + overrides: Partial = {}, +): FirecrackerManagerDependencies { + const client = { + putMachineConfig: jest.fn().mockResolvedValue(undefined), + putBootSource: jest.fn().mockResolvedValue(undefined), + putDrive: jest.fn().mockResolvedValue(undefined), + instanceStart: jest.fn().mockResolvedValue(undefined), + } as unknown as FirecrackerApiClient; + return { + preflight: jest.fn().mockResolvedValue({ + version: '1.16.1', + firecrackerBinary: '/opt/firecracker', + jailerBinary: '/opt/jailer', + kernelPath: '/opt/vmlinux', + rootfsPath: '/opt/rootfs.ext4', + }), + launch: jest.fn().mockReturnValue(processMock()), + mkdir: jest.fn().mockResolvedValue(undefined), + copyFile: jest.fn().mockResolvedValue(undefined), + chmod: jest.fn().mockResolvedValue(undefined), + chown: jest.fn().mockResolvedValue(undefined), + access: jest.fn().mockResolvedValue(undefined), + rm: jest.fn().mockResolvedValue(undefined), + sleep: jest.fn().mockResolvedValue(undefined), + createClient: jest.fn().mockReturnValue(client), + resolveIdentity: jest.fn().mockReturnValue({ uid: 1000, gid: 1000 }), + ...overrides, + }; +} + +describe('FirecrackerManager', () => { + it('constructs unique, contained jail paths', () => { + const first = createFirecrackerRunPaths('/tmp/awf', '/opt/firecracker'); + const second = createFirecrackerRunPaths('/tmp/awf', '/opt/firecracker'); + expect(first.runId).not.toBe(second.runId); + expect(first.jailRoot).toContain('/tmp/awf/firecracker-jailer/firecracker/'); + expect(() => createFirecrackerRunPaths( + '/tmp/awf', + '/opt/firecracker', + '../escape', + )).toThrow(/Unsafe Firecracker run id/); + expect(() => createFirecrackerRunPaths( + '/tmp/awf', + '/opt/firecracker', + 'run_1', + )).toThrow(/Unsafe Firecracker run id/); + expect(() => createFirecrackerRunPaths( + '/tmp/awf', + '/opt/firecracker', + `run-${'a'.repeat(61)}`, + )).toThrow(/Unsafe Firecracker run id/); + }); + + it('launches jailer and configures machine, kernel, and root drive', async () => { + const deps = dependencies(); + const manager = new FirecrackerManager(config(), '/tmp/awf', deps, 'run-1'); + const client = await manager.start(); + + expect(deps.launch).toHaveBeenCalledWith( + '/opt/jailer', + expect.arrayContaining([ + '--id', 'run-1', + '--exec-file', '/opt/firecracker', + '--api-sock', '/run/firecracker.socket', + ]), + expect.objectContaining({ reject: false }), + ); + expect(client.putMachineConfig).toHaveBeenCalledWith({ + vcpu_count: 2, + mem_size_mib: 512, + }); + expect(client.putBootSource).toHaveBeenCalledWith({ + kernel_image_path: '/kernel', + }); + expect(client.putDrive).toHaveBeenCalledWith(expect.objectContaining({ + drive_id: 'rootfs', + path_on_host: '/rootfs', + is_root_device: true, + })); + }); + + it('terminates the partial process and removes its jail on readiness failure', async () => { + const child = processMock(); + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }); + const deps = dependencies({ + launch: jest.fn().mockReturnValue(child), + access: jest.fn().mockRejectedValue(missing), + sleep: jest.fn(async () => new Promise((resolve) => setTimeout(resolve, 2))), + }); + const manager = new FirecrackerManager(config(), '/tmp/awf', deps, 'partial'); + + await expect(manager.start()).rejects.toThrow(/API socket was not ready/); + expect(child.kill).toHaveBeenCalledWith( + 'SIGTERM', + { forceKillAfterTimeout: 2_000 }, + ); + expect(deps.rm).toHaveBeenCalledWith( + '/tmp/awf/firecracker-jailer/firecracker/partial', + { recursive: true, force: true }, + ); + }); + + it('fails fast when jailer exits by signal before API readiness', async () => { + const child = processMock(); + Object.assign(child, { signalCode: 'SIGKILL', kill: jest.fn() }); + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }); + const deps = dependencies({ + launch: jest.fn().mockReturnValue(child), + access: jest.fn().mockRejectedValue(missing), + sleep: jest.fn().mockResolvedValue(undefined), + }); + const manager = new FirecrackerManager(config({ apiTimeoutMs: 2000 }), '/tmp/awf', deps, 'signal'); + + await expect(manager.start()).rejects.toThrow( + /exited before API readiness with code null and signal SIGKILL/, + ); + expect(deps.sleep).not.toHaveBeenCalled(); + }); +}); diff --git a/src/firecracker/manager.ts b/src/firecracker/manager.ts new file mode 100644 index 000000000..365e9bb7b --- /dev/null +++ b/src/firecracker/manager.ts @@ -0,0 +1,262 @@ +import { randomBytes } from 'crypto'; +import { constants, promises as fs } from 'fs'; +import * as path from 'path'; +import execa, { type ExecaChildProcess } from 'execa'; +import type { FirecrackerOptions } from '../types/runtime-options'; +import { FirecrackerApiClient } from './api-client'; +import { runFirecrackerPreflight } from './preflight'; + +const API_SOCKET_NAME = 'firecracker.socket'; +const KERNEL_JAIL_PATH = '/kernel'; +const ROOTFS_JAIL_PATH = '/rootfs'; + +export interface FirecrackerRunPaths { + runId: string; + chrootBaseDir: string; + jailRoot: string; + apiSocketPath: string; + kernelPath: string; + rootfsPath: string; +} + +export interface FirecrackerManagerDependencies { + preflight: typeof runFirecrackerPreflight; + launch( + command: string, + args: string[], + options: { + reject: false; + stdio: ['ignore', 'pipe', 'pipe']; + env: NodeJS.ProcessEnv; + }, + ): ExecaChildProcess; + mkdir(directory: string, options: { recursive: true; mode: number }): Promise; + copyFile(source: string, destination: string, flags: number): Promise; + chmod(filePath: string, mode: number): Promise; + chown(filePath: string, uid: number, gid: number): Promise; + access(filePath: string): Promise; + rm(directory: string, options: { recursive: true; force: true }): Promise; + sleep(milliseconds: number): Promise; + createClient(socketPath: string, timeoutMs: number): FirecrackerApiClient; + resolveIdentity(): { uid: number; gid: number }; +} + +const defaultDependencies: FirecrackerManagerDependencies = { + preflight: runFirecrackerPreflight, + launch: (command, args, options) => execa(command, args, options), + mkdir: fs.mkdir, + copyFile: fs.copyFile, + chmod: fs.chmod, + chown: fs.chown, + access: fs.access, + rm: fs.rm, + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + createClient: (socketPath, timeoutMs) => new FirecrackerApiClient({ socketPath, timeoutMs }), + resolveIdentity: resolveJailerIdentity, +}; + +function parsePositiveIdentity(value: string | undefined): number | undefined { + if (!value || !/^[1-9]\d*$/.test(value)) return undefined; + return Number(value); +} + +function resolveJailerIdentity(): { uid: number; gid: number } { + const uid = parsePositiveIdentity(process.env.SUDO_UID) ?? process.getuid?.(); + const gid = parsePositiveIdentity(process.env.SUDO_GID) ?? process.getgid?.(); + if (uid === undefined || gid === undefined || uid === 0 || gid === 0) { + throw new Error( + 'Firecracker jailer requires a non-root target uid/gid; run through sudo from a non-root account', + ); + } + return { uid, gid }; +} + +export function createFirecrackerRunPaths( + workDir: string, + firecrackerBinary: string, + runId = `awf-${process.pid}-${randomBytes(6).toString('hex')}`, +): FirecrackerRunPaths { + if (!/^[A-Za-z0-9-]{1,64}$/.test(runId)) { + throw new Error(`Unsafe Firecracker run id: ${runId}`); + } + const chrootBaseDir = path.join(workDir, 'firecracker-jailer'); + const jailRoot = path.join( + chrootBaseDir, + path.basename(firecrackerBinary), + runId, + 'root', + ); + return { + runId, + chrootBaseDir, + jailRoot, + apiSocketPath: path.join(jailRoot, 'run', API_SOCKET_NAME), + kernelPath: path.join(jailRoot, KERNEL_JAIL_PATH), + rootfsPath: path.join(jailRoot, ROOTFS_JAIL_PATH), + }; +} + +/** + * Owns one jailer-launched Firecracker process and its partial-start cleanup. + */ +export class FirecrackerManager { + readonly paths: FirecrackerRunPaths; + private process: ExecaChildProcess | undefined; + private client: FirecrackerApiClient | undefined; + + constructor( + private readonly config: FirecrackerOptions, + workDir: string, + private readonly dependencies: FirecrackerManagerDependencies = defaultDependencies, + runId?: string, + ) { + this.paths = createFirecrackerRunPaths(workDir, config.firecrackerBinary, runId); + } + + async start(): Promise { + let startupError: unknown; + try { + const artifacts = await this.dependencies.preflight(this.config); + const identity = this.dependencies.resolveIdentity(); + await this.dependencies.mkdir(this.paths.chrootBaseDir, { + recursive: true, + mode: 0o700, + }); + + this.process = this.dependencies.launch( + this.config.jailerBinary, + [ + '--id', this.paths.runId, + '--exec-file', this.config.firecrackerBinary, + '--uid', String(identity.uid), + '--gid', String(identity.gid), + '--chroot-base-dir', this.paths.chrootBaseDir, + '--', + '--api-sock', `/run/${API_SOCKET_NAME}`, + ], + { + reject: false, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env }, + }, + ); + + await this.waitForApiSocket(); + await this.stageArtifact(artifacts.kernelPath, this.paths.kernelPath, 0o400, identity); + await this.stageArtifact(artifacts.rootfsPath, this.paths.rootfsPath, 0o600, identity); + + this.client = this.dependencies.createClient( + this.paths.apiSocketPath, + this.config.apiTimeoutMs, + ); + await this.client.putMachineConfig({ + vcpu_count: this.config.vcpuCount, + mem_size_mib: this.config.memoryMib, + }); + await this.client.putBootSource({ + kernel_image_path: KERNEL_JAIL_PATH, + }); + await this.client.putDrive({ + drive_id: 'rootfs', + path_on_host: ROOTFS_JAIL_PATH, + is_root_device: true, + is_read_only: false, + }); + return this.client; + } catch (error) { + startupError = error; + } + + try { + await this.stop(); + } catch (cleanupError) { + throw new Error( + `Firecracker startup failed: ${formatError(startupError)}; ` + + `partial-start cleanup also failed: ${formatError(cleanupError)}`, + ); + } + throw startupError; + } + + async startInstance(): Promise { + if (!this.client) throw new Error('Firecracker API is not configured'); + await this.client.instanceStart(); + } + + async stop(): Promise { + let processError: unknown; + if (this.process && this.process.exitCode === null && !this.process.killed) { + const child = this.process; + try { + child.kill('SIGTERM', { forceKillAfterTimeout: 2_000 }); + await child; + if (child.exitCode === null && child.signalCode === null) { + throw new Error('Firecracker process termination was not confirmed'); + } + } catch (error) { + processError = error; + } + } + this.process = undefined; + this.client = undefined; + + try { + await this.dependencies.rm( + path.join( + this.paths.chrootBaseDir, + path.basename(this.config.firecrackerBinary), + this.paths.runId, + ), + { recursive: true, force: true }, + ); + } catch (error) { + if (processError) { + throw new Error( + `Failed to terminate Firecracker: ${formatError(processError)}; ` + + `failed to remove jail: ${formatError(error)}`, + ); + } + throw error; + } + if (processError) throw processError; + } + + private async waitForApiSocket(): Promise { + const deadline = Date.now() + this.config.apiTimeoutMs; + while (Date.now() < deadline) { + if (this.process && (this.process.exitCode != null || this.process.signalCode != null)) { + throw new Error( + `Firecracker jailer exited before API readiness with code ${this.process.exitCode ?? 'null'} ` + + `and signal ${this.process.signalCode ?? 'null'}`, + ); + } + try { + await this.dependencies.access(this.paths.apiSocketPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + await this.dependencies.sleep(25); + } + throw new Error( + `Firecracker API socket was not ready after ${this.config.apiTimeoutMs}ms: ` + + this.paths.apiSocketPath, + ); + } + + private async stageArtifact( + source: string, + destination: string, + mode: number, + identity: { uid: number; gid: number }, + ): Promise { + await this.dependencies.copyFile(source, destination, constants.COPYFILE_EXCL); + await this.dependencies.chown(destination, identity.uid, identity.gid); + await this.dependencies.chmod(destination, mode); + } +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/firecracker/preflight.test.ts b/src/firecracker/preflight.test.ts new file mode 100644 index 000000000..d3de4243c --- /dev/null +++ b/src/firecracker/preflight.test.ts @@ -0,0 +1,169 @@ +import { constants } from 'fs'; +import type { FirecrackerOptions } from '../types/runtime-options'; +import { + parseFirecrackerVersion, + runFirecrackerPreflight, + type FirecrackerPreflightDependencies, +} from './preflight'; + +const digest = 'a'.repeat(64); + +function config(overrides: Partial = {}): FirecrackerOptions { + return { + previewEnabled: true, + firecrackerBinary: '/opt/firecracker', + jailerBinary: '/opt/jailer', + kernelPath: '/opt/vmlinux', + rootfsPath: '/opt/rootfs.ext4', + vcpuCount: 2, + memoryMib: 512, + apiTimeoutMs: 5000, + ...overrides, + }; +} + +function dependencies( + overrides: Partial = {}, +): Partial { + return { + platform: 'linux', + arch: 'x64', + uid: 1000, + access: jest.fn().mockResolvedValue(undefined), + lstat: jest.fn().mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100755, + uid: 0, + }), + runVersion: jest.fn().mockResolvedValue('Firecracker v1.16.1'), + sha256: jest.fn().mockResolvedValue(digest), + ...overrides, + }; +} + +describe('Firecracker preflight', () => { + afterEach(() => { + delete process.env.SUDO_UID; + }); + + it('parses Firecracker and jailer release output', () => { + expect(parseFirecrackerVersion('Firecracker v1.16.1')).toBe('1.16.1'); + expect(parseFirecrackerVersion('Jailer v1.16.1')).toBe('1.16.1'); + expect(() => parseFirecrackerVersion('unknown')).toThrow(/Could not parse/); + }); + + it('pins matching Firecracker and jailer v1.16.1 and verifies configured digests', async () => { + const deps = dependencies(); + const result = await runFirecrackerPreflight(config({ + sha256: { + firecracker: digest, + jailer: digest, + kernel: digest, + rootfs: digest, + }, + }), deps); + + expect(result.version).toBe('1.16.1'); + expect(deps.access).toHaveBeenCalledWith( + '/dev/kvm', + constants.R_OK | constants.W_OK, + ); + expect(deps.sha256).toHaveBeenCalledTimes(4); + }); + + it('rejects inaccessible KVM without checking artifacts', async () => { + const access = jest.fn().mockRejectedValue(new Error('EACCES')); + const lstat = jest.fn(); + await expect(runFirecrackerPreflight( + config(), + dependencies({ access, lstat }), + )).rejects.toThrow(/readable and writable \/dev\/kvm.*EACCES/); + expect(lstat).not.toHaveBeenCalled(); + }); + + it('rejects mismatched versions, unsafe permissions, and digest mismatches', async () => { + const runVersion = jest.fn() + .mockResolvedValueOnce('Firecracker v1.16.1') + .mockResolvedValueOnce('Jailer v1.15.0'); + await expect(runFirecrackerPreflight( + config(), + dependencies({ runVersion }), + )).rejects.toThrow(/versions must match/); + + await expect(runFirecrackerPreflight( + config(), + dependencies({ + lstat: jest.fn().mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100777, + uid: 1000, + }), + }), + )).rejects.toThrow(/must not be group- or world-writable/); + + await expect(runFirecrackerPreflight( + config({ sha256: { kernel: digest } }), + dependencies({ sha256: jest.fn().mockResolvedValue('b'.repeat(64)) }), + )).rejects.toThrow(/SHA-256 mismatch/); + }); + + it('uses SUDO_UID as trusted owner when running under sudo', async () => { + process.env.SUDO_UID = '2001'; + const lstat = jest.fn().mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100755, + uid: 2001, + }); + await expect(runFirecrackerPreflight( + config(), + dependencies({ uid: undefined, lstat }), + )).resolves.toMatchObject({ version: '1.16.1' }); + }); + + it('rejects writable or symlinked parent directories', async () => { + const lstat = jest.fn(async (filePath: string) => { + if (filePath === '/opt') { + return { + isFile: () => false, + isSymbolicLink: () => false, + mode: 0o040777, + uid: 0, + }; + } + return { + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100755, + uid: 0, + }; + }); + await expect(runFirecrackerPreflight( + config(), + dependencies({ lstat }), + )).rejects.toThrow(/parent directory must not be group- or world-writable/); + + const symlinkParent = jest.fn(async (filePath: string) => { + if (filePath === '/opt') { + return { + isFile: () => false, + isSymbolicLink: () => true, + mode: 0o040755, + uid: 0, + }; + } + return { + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100755, + uid: 0, + }; + }); + await expect(runFirecrackerPreflight( + config(), + dependencies({ lstat: symlinkParent }), + )).rejects.toThrow(/parent directory must not be a symbolic link/); + }); +}); diff --git a/src/firecracker/preflight.ts b/src/firecracker/preflight.ts new file mode 100644 index 000000000..493f401a7 --- /dev/null +++ b/src/firecracker/preflight.ts @@ -0,0 +1,267 @@ +import { createHash } from 'crypto'; +import { createReadStream, constants, promises as fs } from 'fs'; +import * as path from 'path'; +import execa from 'execa'; +import { + FIRECRACKER_RELEASE_VERSION, + type FirecrackerOptions, +} from '../types/runtime-options'; + +export interface FirecrackerPreflightDependencies { + platform: NodeJS.Platform; + arch: string; + uid: number; + access(filePath: string, mode: number): Promise; + lstat(filePath: string): Promise<{ + isFile(): boolean; + isSymbolicLink(): boolean; + mode: number; + uid: number; + }>; + runVersion(binaryPath: string): Promise; + sha256(filePath: string): Promise; +} + +const defaultDependencies: FirecrackerPreflightDependencies = { + platform: process.platform, + arch: process.arch, + uid: -1, + access: fs.access, + lstat: fs.lstat, + runVersion: async (binaryPath) => { + const result = await execa(binaryPath, ['--version'], { + reject: false, + timeout: 5_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.exitCode !== 0) { + throw new Error( + `"${binaryPath} --version" exited with code ${result.exitCode}: ${result.stderr.trim()}`, + ); + } + return `${result.stdout}\n${result.stderr}`.trim(); + }, + sha256: calculateSha256, +}; + +export interface FirecrackerPreflightResult { + version: string; + firecrackerBinary: string; + jailerBinary: string; + kernelPath: string; + rootfsPath: string; +} + +export function parseFirecrackerVersion(output: string): string { + const match = output.match(/\bv?(\d+\.\d+\.\d+)\b/); + if (!match) { + throw new Error(`Could not parse Firecracker version from: ${JSON.stringify(output)}`); + } + return match[1]; +} + +export async function calculateSha256(filePath: string): Promise { + const hash = createHash('sha256'); + const stream = createReadStream(filePath); + for await (const chunk of stream) { + hash.update(chunk as Buffer); + } + return hash.digest('hex'); +} + +async function assertTrustedRegularFile( + label: string, + filePath: string, + accessMode: number, + dependencies: FirecrackerPreflightDependencies, +): Promise { + if (!path.isAbsolute(filePath)) { + throw new Error(`${label} path must be absolute: ${filePath}`); + } + await assertTrustedAncestorChain(label, filePath, dependencies); + const stat = await dependencies.lstat(filePath); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`${label} must be a regular file and not a symbolic link: ${filePath}`); + } + if ((stat.mode & 0o022) !== 0) { + throw new Error(`${label} must not be group- or world-writable: ${filePath}`); + } + if (stat.uid !== 0 && stat.uid !== dependencies.uid) { + throw new Error( + `${label} must be owned by root or uid ${dependencies.uid}; found uid ${stat.uid}: ${filePath}`, + ); + } + try { + await dependencies.access(filePath, accessMode); + } catch (error) { + throw new Error( + `${label} does not have the required host access: ${filePath}: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function parsePositiveUid(value: string | undefined): number | undefined { + if (!value || !/^[1-9]\d*$/.test(value)) return undefined; + return Number(value); +} + +function resolveTrustedOperatorUid(): number { + return parsePositiveUid(process.env.SUDO_UID) ?? (process.getuid?.() ?? -1); +} + +async function assertTrustedAncestorChain( + label: string, + filePath: string, + dependencies: FirecrackerPreflightDependencies, +): Promise { + const { root } = path.parse(filePath); + const segments = filePath.slice(root.length).split('/').filter((segment) => segment.length > 0); + let ancestor = root; + for (const segment of segments.slice(0, -1)) { + ancestor = path.join(ancestor, segment); + const stat = await dependencies.lstat(ancestor); + if (stat.isSymbolicLink()) { + throw new Error( + `${label} parent directory must not be a symbolic link: ${ancestor}`, + ); + } + if ((stat.mode & 0o022) !== 0) { + throw new Error( + `${label} parent directory must not be group- or world-writable: ${ancestor}`, + ); + } + if (stat.uid !== 0 && stat.uid !== dependencies.uid) { + throw new Error( + `${label} parent directory must be owned by root or uid ${dependencies.uid}; ` + + `found uid ${stat.uid}: ${ancestor}`, + ); + } + } +} + +async function assertDigest( + label: string, + filePath: string, + expected: string | undefined, + dependencies: FirecrackerPreflightDependencies, +): Promise { + if (!expected) return; + if (!/^[a-fA-F0-9]{64}$/.test(expected)) { + throw new Error(`${label} SHA-256 must contain exactly 64 hexadecimal characters`); + } + const actual = await dependencies.sha256(filePath); + if (actual.toLowerCase() !== expected.toLowerCase()) { + throw new Error( + `${label} SHA-256 mismatch: expected ${expected.toLowerCase()}, got ${actual.toLowerCase()}`, + ); + } +} + +/** + * Fail-closed host and artifact validation for Firecracker v1.16.1. + */ +export async function runFirecrackerPreflight( + config: FirecrackerOptions, + overrides: Partial = {}, +): Promise { + const dependencies = { + ...defaultDependencies, + ...overrides, + uid: overrides.uid ?? resolveTrustedOperatorUid(), + }; + if (dependencies.platform !== 'linux') { + throw new Error(`Firecracker requires Linux with KVM; found ${dependencies.platform}`); + } + if (dependencies.arch !== 'x64' && dependencies.arch !== 'arm64') { + throw new Error( + `Firecracker supports only x86_64 and aarch64; found Node architecture ${dependencies.arch}`, + ); + } + if (!config.kernelPath || !config.rootfsPath) { + throw new Error('Firecracker requires both guest kernel and rootfs artifact paths'); + } + + try { + await dependencies.access('/dev/kvm', constants.R_OK | constants.W_OK); + } catch (error) { + throw new Error( + 'Firecracker requires readable and writable /dev/kvm: ' + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + await assertTrustedRegularFile( + 'Firecracker binary', + config.firecrackerBinary, + constants.R_OK | constants.X_OK, + dependencies, + ); + await assertTrustedRegularFile( + 'Firecracker jailer binary', + config.jailerBinary, + constants.R_OK | constants.X_OK, + dependencies, + ); + await assertTrustedRegularFile( + 'Firecracker guest kernel', + config.kernelPath, + constants.R_OK, + dependencies, + ); + await assertTrustedRegularFile( + 'Firecracker rootfs', + config.rootfsPath, + constants.R_OK, + dependencies, + ); + + const firecrackerVersion = parseFirecrackerVersion( + await dependencies.runVersion(config.firecrackerBinary), + ); + const jailerVersion = parseFirecrackerVersion( + await dependencies.runVersion(config.jailerBinary), + ); + if (firecrackerVersion !== jailerVersion) { + throw new Error( + `Firecracker and jailer versions must match; found ${firecrackerVersion} and ${jailerVersion}`, + ); + } + if (firecrackerVersion !== FIRECRACKER_RELEASE_VERSION) { + throw new Error( + `Firecracker is pinned to v${FIRECRACKER_RELEASE_VERSION}; found v${firecrackerVersion}`, + ); + } + + await assertDigest( + 'Firecracker binary', + config.firecrackerBinary, + config.sha256?.firecracker, + dependencies, + ); + await assertDigest( + 'Firecracker jailer binary', + config.jailerBinary, + config.sha256?.jailer, + dependencies, + ); + await assertDigest( + 'Firecracker guest kernel', + config.kernelPath, + config.sha256?.kernel, + dependencies, + ); + await assertDigest( + 'Firecracker rootfs', + config.rootfsPath, + config.sha256?.rootfs, + dependencies, + ); + + return { + version: firecrackerVersion, + firecrackerBinary: config.firecrackerBinary, + jailerBinary: config.jailerBinary, + kernelPath: config.kernelPath, + rootfsPath: config.rootfsPath, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index a14f8ee87..37e5a7fa9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,6 +12,16 @@ export type * from './wrapper-config'; export { type UpstreamProxyConfig } from './upstream-proxy'; export { type LogLevel } from './log-level'; +export { + type FirecrackerArtifactDigests, + type FirecrackerOptions, + FIRECRACKER_RELEASE_VERSION, + FIRECRACKER_DEFAULT_BINARY, + FIRECRACKER_DEFAULT_JAILER_BINARY, + FIRECRACKER_DEFAULT_VCPU_COUNT, + FIRECRACKER_DEFAULT_MEMORY_MIB, + FIRECRACKER_DEFAULT_API_TIMEOUT_MS, +} from './runtime-options'; export { type RateLimitConfig } from './rate-limit'; export { type FlagValidationResult } from './validation'; diff --git a/src/types/runtime-options.ts b/src/types/runtime-options.ts index 960347fb8..34f9bd2ef 100644 --- a/src/types/runtime-options.ts +++ b/src/types/runtime-options.ts @@ -4,6 +4,38 @@ import type { LogLevel } from './log-level'; +export const FIRECRACKER_RELEASE_VERSION = '1.16.1'; +export const FIRECRACKER_DEFAULT_BINARY = '/usr/local/bin/firecracker'; +export const FIRECRACKER_DEFAULT_JAILER_BINARY = '/usr/local/bin/jailer'; +export const FIRECRACKER_DEFAULT_VCPU_COUNT = 2; +export const FIRECRACKER_DEFAULT_MEMORY_MIB = 512; +export const FIRECRACKER_DEFAULT_API_TIMEOUT_MS = 5_000; + +export interface FirecrackerArtifactDigests { + firecracker?: string; + jailer?: string; + kernel?: string; + rootfs?: string; +} + +/** + * Preview control-plane configuration for the Firecracker microVM runtime. + * + * Networking and guest command execution are intentionally not part of this + * configuration surface yet. + */ +export interface FirecrackerOptions { + previewEnabled: boolean; + firecrackerBinary: string; + jailerBinary: string; + kernelPath?: string; + rootfsPath?: string; + vcpuCount: number; + memoryMib: number; + apiTimeoutMs: number; + sha256?: FirecrackerArtifactDigests; +} + export interface RuntimeOptions { /** * The command to execute inside the firewall container @@ -161,4 +193,7 @@ export interface RuntimeOptions { targetPath?: string; }; }; + + /** Firecracker microVM control-plane settings. */ + firecracker?: FirecrackerOptions; }