From c139c5a25bab742a35e5453569d696aadadbc62f Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sat, 8 Aug 2026 19:50:46 -0700 Subject: [PATCH 1/4] feat: integrate Firecracker agent runtime Wire the preview Firecracker backend through live Compose topology discovery, checksummed guest artifacts, vsock execution, fail-closed validation, and recoverable lifecycle cleanup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/awf-config-spec.md | 19 +- docs/awf-config.schema.json | 15 +- src/awf-config-schema.json | 15 +- src/cli-options.ts | 8 +- src/commands/build-config.ts | 4 + src/commands/main-action.ts | 25 +- src/commands/validators/config-assembly.ts | 27 ++ src/commands/validators/security-mode.ts | 7 +- src/config-file.ts | 1 + src/config-mapper.ts | 2 + src/external-runtime-backend-resolver.ts | 5 + src/external-runtime-backend.test.ts | 10 +- src/external-runtime-backend.ts | 2 + src/firecracker-runtime-backend.test.ts | 201 +++++++++++ src/firecracker-runtime-backend.ts | 401 +++++++++++++++++++-- src/firecracker/config.test.ts | 6 +- src/firecracker/infrastructure.test.ts | 101 ++++++ src/firecracker/infrastructure.ts | 233 ++++++++++++ src/firecracker/manager.test.ts | 48 +++ src/firecracker/manager.ts | 58 ++- src/firecracker/preflight.test.ts | 6 +- src/firecracker/preflight.ts | 45 ++- src/firecracker/runtime-validation.ts | 92 +++++ src/types/runtime-options.ts | 7 +- 24 files changed, 1269 insertions(+), 69 deletions(-) create mode 100644 src/firecracker-runtime-backend.test.ts create mode 100644 src/firecracker/infrastructure.test.ts create mode 100644 src/firecracker/infrastructure.ts create mode 100644 src/firecracker/runtime-validation.ts diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 519bdc7f5..543c947a4 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -189,12 +189,13 @@ 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, 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.)* +- `container.containerRuntime` → `--container-runtime` *(user-facing runtime name: `"gvisor"` for OCI runtime in compose, `"sbx"` for Docker sbx microVM, or `"firecracker"` for the explicit Firecracker v1.16.1 workload preview. For gvisor: translates to `"runsc"`, injects `extra_hosts` for DNS workaround. For sbx and Firecracker: infrastructure stays in Compose while the primary agent runs in a microVM.)* - `firecracker.previewEnabled` → `--firecracker-preview` - `firecracker.firecrackerBinary` → `--firecracker-binary` - `firecracker.jailerBinary` → `--firecracker-jailer-binary` - `firecracker.kernelPath` → `--firecracker-kernel` - `firecracker.rootfsPath` → `--firecracker-rootfs` +- `firecracker.supervisorPath` → `--firecracker-supervisor` - `firecracker.vcpuCount` → `--firecracker-vcpus` - `firecracker.memoryMib` → `--firecracker-memory-mib` - `firecracker.apiTimeoutMs` → `--firecracker-api-timeout-ms` @@ -202,6 +203,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `firecracker.sha256.jailer` → `--firecracker-jailer-sha256` - `firecracker.sha256.kernel` → `--firecracker-kernel-sha256` - `firecracker.sha256.rootfs` → `--firecracker-rootfs-sha256` +- `firecracker.sha256.supervisor` → `--firecracker-supervisor-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)* @@ -261,13 +263,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. +The `firecracker` surface is an explicit workload preview pinned to Firecracker +v1.16.1 on Linux/KVM (`x86_64` or `aarch64`). It requires strict network +isolation, a local Unix-socket Docker daemon, the matching jailer, and explicit +SHA-256 digests for Firecracker, jailer, kernel, rootfs, and the AWF guest +supervisor. AWF starts Compose infrastructure only, attaches the jailed +microVM to the proven internal bridge, and executes through vsock. Host access, +DinD, extra mounts, TTY, topology peers, and enclaves fail closed in this +preview. Selecting `firecracker` never falls back to another runtime. 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. diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 9c084ab4d..c72630fd1 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -652,19 +652,19 @@ "sbx", "firecracker" ], - "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." + "description": "Runtime for the primary agent. \"gvisor\" uses runsc in Docker Compose. \"sbx\" uses a Docker sbx microVM. \"firecracker\" selects the explicit Linux/KVM Firecracker v1.16.1 workload preview. 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.", + "description": "Firecracker v1.16.1 workload preview configuration. Requires strict network isolation, a local Docker daemon, KVM, jailer, and explicitly checksummed artifacts. Selecting this runtime never falls back to Docker.", "additionalProperties": false, "properties": { "previewEnabled": { "type": "boolean", "default": false, - "description": "Explicitly acknowledge the incomplete Firecracker control-plane preview. This does not enable workload execution." + "description": "Explicitly enable Firecracker preview workload execution." }, "firecrackerBinary": { "type": "string", @@ -682,6 +682,10 @@ "type": "string", "description": "Absolute path to the trusted guest root filesystem image." }, + "supervisorPath": { + "type": "string", + "description": "Absolute path to the built AWF Firecracker guest supervisor." + }, "vcpuCount": { "type": "integer", "minimum": 1, @@ -702,7 +706,7 @@ }, "sha256": { "type": "object", - "description": "Optional pinned SHA-256 digests for trusted Firecracker artifacts.", + "description": "Pinned SHA-256 digests for trusted Firecracker artifacts. All entries are required for preview workload execution.", "additionalProperties": false, "properties": { "firecracker": { @@ -716,6 +720,9 @@ }, "rootfs": { "$ref": "#/$defs/sha256Digest" + }, + "supervisor": { + "$ref": "#/$defs/sha256Digest" } } } diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 9c084ab4d..c72630fd1 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -652,19 +652,19 @@ "sbx", "firecracker" ], - "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." + "description": "Runtime for the primary agent. \"gvisor\" uses runsc in Docker Compose. \"sbx\" uses a Docker sbx microVM. \"firecracker\" selects the explicit Linux/KVM Firecracker v1.16.1 workload preview. 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.", + "description": "Firecracker v1.16.1 workload preview configuration. Requires strict network isolation, a local Docker daemon, KVM, jailer, and explicitly checksummed artifacts. Selecting this runtime never falls back to Docker.", "additionalProperties": false, "properties": { "previewEnabled": { "type": "boolean", "default": false, - "description": "Explicitly acknowledge the incomplete Firecracker control-plane preview. This does not enable workload execution." + "description": "Explicitly enable Firecracker preview workload execution." }, "firecrackerBinary": { "type": "string", @@ -682,6 +682,10 @@ "type": "string", "description": "Absolute path to the trusted guest root filesystem image." }, + "supervisorPath": { + "type": "string", + "description": "Absolute path to the built AWF Firecracker guest supervisor." + }, "vcpuCount": { "type": "integer", "minimum": 1, @@ -702,7 +706,7 @@ }, "sha256": { "type": "object", - "description": "Optional pinned SHA-256 digests for trusted Firecracker artifacts.", + "description": "Pinned SHA-256 digests for trusted Firecracker artifacts. All entries are required for preview workload execution.", "additionalProperties": false, "properties": { "firecracker": { @@ -716,6 +720,9 @@ }, "rootfs": { "$ref": "#/$defs/sha256Digest" + }, + "supervisor": { + "$ref": "#/$defs/sha256Digest" } } } diff --git a/src/cli-options.ts b/src/cli-options.ts index 2fecf7926..afc6cb7eb 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -175,19 +175,20 @@ 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' + + ' "firecracker" — explicit Linux/KVM Firecracker v1.16.1 preview.\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.', + 'Enable the Firecracker v1.16.1 workload-execution preview.\n' + + ' Requires Linux/KVM, local Docker, jailer, and pinned guest artifacts.', 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-supervisor ', 'Path to the built AWF Firecracker guest supervisor.') .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).') @@ -195,6 +196,7 @@ program .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.') + .option('--firecracker-supervisor-sha256 ', 'Expected SHA-256 digest of the AWF guest supervisor.') // -- Container Configuration -- .option( diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 9338e3778..7a5578b42 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -243,6 +243,7 @@ function buildFirecrackerConfig( 'firecrackerJailerBinary', 'firecrackerKernel', 'firecrackerRootfs', + 'firecrackerSupervisor', 'firecrackerVcpus', 'firecrackerMemoryMib', 'firecrackerApiTimeoutMs', @@ -250,6 +251,7 @@ function buildFirecrackerConfig( 'firecrackerJailerSha256', 'firecrackerKernelSha256', 'firecrackerRootfsSha256', + 'firecrackerSupervisorSha256', ].some((key) => options[key] !== undefined); if (!selected && !configured) return undefined; @@ -258,6 +260,7 @@ function buildFirecrackerConfig( jailer: options.firecrackerJailerSha256 as string | undefined, kernel: options.firecrackerKernelSha256 as string | undefined, rootfs: options.firecrackerRootfsSha256 as string | undefined, + supervisor: options.firecrackerSupervisorSha256 as string | undefined, }; return { @@ -269,6 +272,7 @@ function buildFirecrackerConfig( FIRECRACKER_DEFAULT_JAILER_BINARY, kernelPath: options.firecrackerKernel as string | undefined, rootfsPath: options.firecrackerRootfs as string | undefined, + supervisorPath: options.firecrackerSupervisor as string | undefined, vcpuCount: parseFirecrackerPositiveInteger( options.firecrackerVcpus, '--firecracker-vcpus', diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index f3956031c..f4163fb33 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -99,12 +99,25 @@ function buildCleanupFn( externalRuntimeBackend?: ExternalAgentRuntimeBackend, ) { return async (signal?: string) => { + const cleanupErrors: unknown[] = []; if (signal) { logger.info(`Received ${signal}, cleaning up...`); } - if (externalRuntimeBackend && !config.keepContainers) { - await externalRuntimeBackend.stop(); + if (externalRuntimeBackend) { + try { + if (config.keepContainers && externalRuntimeBackend.preserve) { + await externalRuntimeBackend.preserve(); + } else if (!config.keepContainers) { + await externalRuntimeBackend.stop(); + } + } catch (error) { + cleanupErrors.push(error); + logger.warn( + 'External runtime cleanup failed; continuing with infrastructure teardown.', + error, + ); + } } // Let the enclave server emit final cleanup telemetry before preserving @@ -169,6 +182,14 @@ function buildCleanupFn( logger.info(`Squid logs available at: ${config.workDir}/squid-logs/`); logger.info(`Host iptables rules preserved (--keep-containers enabled)`); } + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new Error( + `Cleanup failed: ${cleanupErrors.map((error) => ( + error instanceof Error ? error.message : String(error) + )).join('; ')}`, + ); + } }; } diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index 8d26386c1..01a1d6f6d 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -9,6 +9,11 @@ import { validateInfrastructureOptions, applyRateLimitConfig, validateFeatureFla import { applySecurityMode } from './security-mode'; import { validateHostAccessConfig } from './network-access-validator'; import { validateApiProxyOptions, validateCopilotModelOption } from './api-proxy-validator'; +import { + assertFirecrackerPreSecurityCompatibility, + assertFirecrackerRuntimeCompatibility, + assertFirecrackerSelection, +} from '../../firecracker/runtime-validation'; // --------------------------------------------------------------------------- // Public API @@ -70,7 +75,29 @@ export function assembleAndValidateConfig( }); validateInfrastructureOptions(config); + try { + assertFirecrackerSelection(config); + } catch (error) { + logger.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + if (config.containerRuntime === 'firecracker') { + try { + assertFirecrackerPreSecurityCompatibility(config); + } catch (error) { + logger.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } applySecurityMode(config); + if (config.containerRuntime === 'firecracker') { + try { + assertFirecrackerRuntimeCompatibility(config); + } catch (error) { + logger.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } applyAgentTimeout(options.agentTimeout as string | undefined, config, logger); applyRateLimitConfig(config, options); validateFeatureFlagCompatibility(config); diff --git a/src/commands/validators/security-mode.ts b/src/commands/validators/security-mode.ts index c9771c0b8..88d2e5fd6 100644 --- a/src/commands/validators/security-mode.ts +++ b/src/commands/validators/security-mode.ts @@ -30,8 +30,9 @@ export function applySecurityMode(config: WrapperConfig): void { // --- strict security (default) --- - // MicroVM runtimes (e.g. sbx) enforce isolation at the hypervisor layer via - // DOCKER_SANDBOXES_PROXY; Docker network topology does not apply to them. + // Docker sbx enforces isolation through its hypervisor proxy and does not use + // Docker topology. Firecracker is also a microVM, but explicitly attaches its + // host-side veth to AWF's proven internal bridge, so topology remains required. const isMicroVmRuntime = !runtimeUsesComposeAgent(config.containerRuntime); if (isMicroVmRuntime && config.pidsLimit !== undefined) { @@ -41,7 +42,7 @@ export function applySecurityMode(config: WrapperConfig): void { ); } - if (!isMicroVmRuntime) { + if (!isMicroVmRuntime || config.containerRuntime === 'firecracker') { // Force network-isolation on. // Only warn when explicitly disabled (=== false); undefined means "not set by user". if (!config.networkIsolation) { diff --git a/src/config-file.ts b/src/config-file.ts index 7edc5a1a5..936d6c83d 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -129,6 +129,7 @@ export interface AwfFileConfig { jailerBinary?: string; kernelPath?: string; rootfsPath?: string; + supervisorPath?: string; vcpuCount?: number; memoryMib?: number; apiTimeoutMs?: number; diff --git a/src/config-mapper.ts b/src/config-mapper.ts index 360953239..5bcbaeabf 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -119,6 +119,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { )).toThrow('No external agent runtime backend is registered for "sbx"'); }); - it('resolves Firecracker to a fail-closed preview backend', async () => { + it('requires explicit Firecracker preview opt-in during resolution', () => { 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(() => resolveExternalRuntimeBackend(config, startInfrastructure)) + .toThrow(/explicit --firecracker-preview/); expect(startInfrastructure).not.toHaveBeenCalled(); }); it('adapts start and exec without changing arguments or exit codes', async () => { diff --git a/src/external-runtime-backend.ts b/src/external-runtime-backend.ts index 5c75a4495..5eb689c18 100644 --- a/src/external-runtime-backend.ts +++ b/src/external-runtime-backend.ts @@ -14,6 +14,8 @@ export interface ExternalAgentRuntimeBackend { exec: WorkflowDependencies['runAgentCommand']; collectDiagnostics(): Promise; stop(): Promise; + /** Safely quiesces the runtime while retaining inspectable state. */ + preserve?(): Promise; } export type ExternalRuntimeWorkflowDependencies = Pick< diff --git a/src/firecracker-runtime-backend.test.ts b/src/firecracker-runtime-backend.test.ts new file mode 100644 index 000000000..cc69df060 --- /dev/null +++ b/src/firecracker-runtime-backend.test.ts @@ -0,0 +1,201 @@ +import { PassThrough } from 'stream'; +import type { WrapperConfig } from './types'; +import { + FirecrackerRuntimeBackend, + assertFirecrackerPreSecurityCompatibility, + buildFirecrackerGuestEnvironment, + type FirecrackerRuntimeBackendDependencies, +} from './firecracker-runtime-backend'; +import { assertFirecrackerSelection } from './firecracker/runtime-validation'; +import type { FirecrackerInfrastructureSnapshot } from './firecracker/infrastructure'; + +const digest = 'a'.repeat(64); + +function config(overrides: Partial = {}): WrapperConfig { + return { + containerRuntime: 'firecracker', + firecracker: { + previewEnabled: true, + firecrackerBinary: '/opt/firecracker', + jailerBinary: '/opt/jailer', + kernelPath: '/opt/kernel', + rootfsPath: '/opt/rootfs', + supervisorPath: '/opt/supervisor', + vcpuCount: 2, + memoryMib: 512, + apiTimeoutMs: 5000, + sha256: { + firecracker: digest, + jailer: digest, + kernel: digest, + rootfs: digest, + supervisor: digest, + }, + }, + agentCommand: 'printf hello', + allowedDomains: ['github.com'], + workDir: '/tmp/awf', + keepContainers: false, + networkIsolation: true, + legacySecurity: false, + enableApiProxy: true, + enableDind: false, + enableHostAccess: false, + tty: false, + logLevel: 'info', + buildLocal: false, + skipPull: true, + imageRegistry: 'registry', + imageTag: 'tag', + envAll: false, + sslBump: false, + enableDlp: false, + ...overrides, + } as WrapperConfig; +} + +function infrastructure(): FirecrackerInfrastructureSnapshot { + return { + networkId: 'a'.repeat(64), + bridgeName: 'br-aaaaaaaaaaaa', + subnet: '172.30.0.0/24', + gateway: '172.30.0.1', + squidIp: '172.30.0.10', + apiProxyIp: '172.30.0.30', + revalidate: jest.fn().mockResolvedValue(undefined), + }; +} + +function harness(overrides: Partial = {}) { + const order: string[] = []; + const stdin = new PassThrough(); + const manager = { + paths: { jailRoot: '/tmp/awf/jail' }, + guestIp: '100.64.0.2', + networkNamespace: 'awffc-test', + start: jest.fn(async () => { order.push('vm-config'); }), + startInstance: jest.fn(async () => { order.push('vm-start'); }), + execute: jest.fn() + .mockImplementationOnce(async () => { + order.push('probe'); + return { requestId: 'probe', exitCode: 0, signal: null, timedOut: false }; + }) + .mockImplementationOnce(async () => ({ + requestId: 'agent', + exitCode: 23, + signal: null, + timedOut: false, + })), + cancel: jest.fn().mockResolvedValue(undefined), + writeStdin: jest.fn().mockResolvedValue(undefined), + endStdin: jest.fn().mockResolvedValue(undefined), + stop: jest.fn(async () => { order.push('vm-stop'); }), + }; + const infra = infrastructure(); + (infra.revalidate as jest.Mock).mockImplementation(async () => { + order.push('revalidate'); + }); + const deps: FirecrackerRuntimeBackendDependencies = { + startInfrastructure: jest.fn(async () => { order.push('compose'); }), + preflight: jest.fn(async () => { order.push('preflight'); }), + resolveInfrastructure: jest.fn(async () => infra), + createManager: jest.fn(() => manager), + workspacePath: () => '/workspace-host', + homePath: () => '/home/runner', + identity: () => ({ uid: 1000, gid: 1000 }), + stdin, + stdout: new PassThrough(), + stderr: new PassThrough(), + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, + ...overrides, + }; + return { order, manager, infra, deps, stdin }; +} + +describe('Firecracker runtime backend', () => { + it('starts infrastructure, revalidates it, boots and probes before execution', async () => { + const { order, manager, deps, stdin } = harness(); + const backend = new FirecrackerRuntimeBackend(config(), deps); + + await backend.start('/tmp/awf', ['github.com']); + const execution = backend.exec('/tmp/awf', ['github.com'], undefined, 1); + stdin.end('input'); + await expect(execution).resolves.toEqual({ exitCode: 23 }); + await backend.stop(); + + expect(order).toEqual([ + 'preflight', + 'compose', + 'revalidate', + 'vm-config', + 'vm-start', + 'probe', + 'vm-stop', + ]); + expect(manager.execute).toHaveBeenNthCalledWith(2, expect.objectContaining({ + argv: ['/bin/bash', '-lc', 'printf hello'], + cwd: '/workspace', + uid: 1000, + gid: 1000, + timeoutMs: 60_000, + })); + expect(manager.writeStdin).toHaveBeenCalledWith( + Buffer.from('input'), + expect.stringMatching(/^agent-/), + ); + }); + + it('stops the partial VM when readiness probing fails', async () => { + const { manager, deps } = harness(); + manager.execute.mockReset().mockResolvedValue({ + requestId: 'probe', + exitCode: 41, + signal: null, + timedOut: false, + }); + const backend = new FirecrackerRuntimeBackend(config(), deps); + + await expect(backend.start('/tmp/awf', ['github.com'])) + .rejects.toThrow(/connectivity probe failed/); + expect(manager.stop).toHaveBeenCalledTimes(1); + }); + + it('preserves sanitized env values without leaking real provider secrets', () => { + const secret = 'sk-real-provider-secret'; + const environment = buildFirecrackerGuestEnvironment( + config({ + openaiApiKey: secret, + additionalEnv: { + SAFE_SETTING: 'enabled', + OPENAI_API_KEY: secret, + }, + }), + infrastructure(), + ); + + expect(environment.SAFE_SETTING).toBe('enabled'); + expect(environment.OPENAI_API_KEY).not.toBe(secret); + expect(Object.values(environment)).not.toContain(secret); + expect(environment.HTTP_PROXY).toBe('http://172.30.0.10:3128'); + expect(environment.HOME).toBe('/workspace/.awf-home'); + }); + + it('rejects unsupported strict-security and topology combinations', () => { + expect(() => assertFirecrackerPreSecurityCompatibility( + config({ enableDind: true }), + )).toThrow(/Docker-in-Docker/); + expect(() => assertFirecrackerPreSecurityCompatibility( + config({ enableHostAccess: true }), + )).toThrow(/host access/); + expect(() => assertFirecrackerPreSecurityCompatibility( + config({ enclaves: { enabled: true } } as Partial), + )).toThrow(/MCP gateway path/); + expect(() => assertFirecrackerSelection( + config({ containerRuntime: 'gvisor' }), + )).toThrow(/require --container-runtime firecracker/); + }); +}); diff --git a/src/firecracker-runtime-backend.ts b/src/firecracker-runtime-backend.ts index 11033f7d6..073a134ba 100644 --- a/src/firecracker-runtime-backend.ts +++ b/src/firecracker-runtime-backend.ts @@ -1,64 +1,407 @@ +import type { Readable, Writable } from 'stream'; import type { WorkflowDependencies } from './cli-workflow'; import type { ExternalAgentRuntimeBackend } from './external-runtime-backend'; +import { + API_PROXY_IP, + NETWORK_SUBNET, + SQUID_IP, +} from './config/network-policy'; +import { + resolveFirecrackerInfrastructure, + type FirecrackerInfrastructureSnapshot, +} from './firecracker/infrastructure'; +import { FirecrackerManager } from './firecracker/manager'; import { runFirecrackerPreflight } from './firecracker/preflight'; -import type { WrapperConfig } from './types'; +import type { + FirecrackerGuestExecutionRequest, + FirecrackerGuestExecutionResult, +} from './firecracker/vsock-client'; +import { getRealUserHome, getSafeHostGid, getSafeHostUid } from './host-identity'; +import { logger } from './logger'; +import { buildAgentEnvironment } from './services/agent-service'; +import { buildAgentCredentialEnv } from './services/api-proxy-credential-env'; +import type { FirecrackerOptions, WrapperConfig } from './types'; +import { + assertFirecrackerRuntimeCompatibility, + requireFirecrackerConfig, +} from './firecracker/runtime-validation'; +export { + assertFirecrackerPreSecurityCompatibility, + assertFirecrackerRuntimeCompatibility, +} from './firecracker/runtime-validation'; -export const FIRECRACKER_INCOMPLETE_CAPABILITY_ERROR = - 'Firecracker runtime workload execution is unavailable in this preview: ' + - 'final runtime dispatch and infrastructure handoff are not integrated'; +const FIRECRACKER_GUEST_WORKSPACE = '/workspace'; +const FIRECRACKER_GUEST_HOME = `${FIRECRACKER_GUEST_WORKSPACE}/.awf-home`; +const FIRECRACKER_PROBE_TIMEOUT_MS = 15_000; +const FIRECRACKER_CANCEL_GRACE_MS = 3_000; + +interface FirecrackerBackendLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; +} + +interface FirecrackerManagerAdapter { + readonly paths: Pick; + readonly guestIp?: string; + readonly networkNamespace?: string; + start(): Promise; + startInstance(): Promise; + execute(request: FirecrackerGuestExecutionRequest): Promise; + cancel(reason?: string, requestId?: string): Promise; + writeStdin(data: Buffer, requestId?: string): Promise; + endStdin(requestId?: string): Promise; + stop(options?: { preserve?: boolean }): Promise; +} export interface FirecrackerRuntimeBackendDependencies { startInfrastructure: WorkflowDependencies['startContainers']; - preflight: typeof runFirecrackerPreflight; + preflight(config: FirecrackerOptions): Promise; + resolveInfrastructure(enableApiProxy: boolean): Promise; + createManager( + config: FirecrackerOptions, + workDir: string, + infrastructure: FirecrackerInfrastructureSnapshot, + workspacePath: string, + homePath: string, + ): FirecrackerManagerAdapter; + workspacePath(): string; + homePath(): string; + identity(): { uid: number; gid: number }; + stdin: Readable & { isTTY?: boolean }; + stdout: Writable; + stderr: Writable; + logger: FirecrackerBackendLogger; } -/** - * Fail-closed backend boundary for the Firecracker control-plane preview. - * - * The manager primitives are intentionally not dispatched by the main workflow - * until the final runtime-selection layer integrates infrastructure discovery, - * sanitized environment assembly, and required probes. FirecrackerManager - * separately refuses to launch without host-side network enforcement. - */ +function defaultDependencies( + startInfrastructure: WorkflowDependencies['startContainers'], +): FirecrackerRuntimeBackendDependencies { + return { + startInfrastructure, + preflight: runFirecrackerPreflight, + resolveInfrastructure: resolveFirecrackerInfrastructure, + createManager: (config, workDir, infrastructure, workspacePath, homePath) => + new FirecrackerManager( + config, + workDir, + undefined, + undefined, + { + infrastructureBridge: infrastructure.bridgeName, + enableApiProxy: Boolean(infrastructure.apiProxyIp), + }, + { + workspacePath, + homePath, + supervisorBinaryPath: config.supervisorPath!, + supervisorSha256: config.sha256!.supervisor!, + }, + ), + workspacePath: () => process.env.GITHUB_WORKSPACE || process.cwd(), + homePath: getRealUserHome, + identity: () => ({ + uid: Number(getSafeHostUid()), + gid: Number(getSafeHostGid()), + }), + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + logger, + }; +} + +/** Stateful adapter for an explicitly enabled, fail-closed Firecracker microVM. */ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { readonly runtime = 'firecracker'; + private manager: FirecrackerManagerAdapter | undefined; + private environment: Record | undefined; + private activeExecution: + | { requestId: string; promise: Promise } + | undefined; + private stopped = false; + private stopping: Promise | undefined; + 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.', - ); - } + const firecracker = requireFirecrackerConfig(this.config); + assertFirecrackerRuntimeCompatibility(this.config, firecracker); await this.dependencies.preflight(firecracker); } - readonly start: WorkflowDependencies['startContainers'] = async () => { + readonly start: WorkflowDependencies['startContainers'] = async ( + workDir, + allowedDomains, + proxyLogsDir, + skipPull, + onNetworkReady, + onInfrastructureReady, + ) => { await this.preflight(); - throw new Error(FIRECRACKER_INCOMPLETE_CAPABILITY_ERROR); + await this.dependencies.startInfrastructure( + workDir, + allowedDomains, + proxyLogsDir, + skipPull, + onNetworkReady, + onInfrastructureReady, + ); + + const firecracker = requireFirecrackerConfig(this.config); + const infrastructure = await this.dependencies.resolveInfrastructure( + Boolean(this.config.enableApiProxy), + ); + this.manager = this.dependencies.createManager( + firecracker, + workDir, + infrastructure, + this.dependencies.workspacePath(), + this.dependencies.homePath(), + ); + + try { + await infrastructure.revalidate(); + await this.manager.start(); + if (!this.manager.guestIp) { + throw new Error('Firecracker manager did not expose the configured guest IP'); + } + this.environment = buildFirecrackerGuestEnvironment( + this.config, + infrastructure, + this.manager.guestIp, + ); + await this.manager.startInstance(); + await this.probeGuestConnectivity(); + } catch (error) { + try { + await this.manager.stop(); + } catch (cleanupError) { + const combined = new Error( + `Firecracker startup failed: ${formatError(error)}; ` + + `microVM cleanup also failed: ${formatError(cleanupError)}`, + ); + Object.defineProperty(combined, 'cause', { value: error }); + Object.assign(combined, { cleanupCause: cleanupError }); + throw combined; + } + throw error; + } }; - readonly exec: WorkflowDependencies['runAgentCommand'] = async () => { - throw new Error(FIRECRACKER_INCOMPLETE_CAPABILITY_ERROR); + readonly exec: WorkflowDependencies['runAgentCommand'] = async ( + _workDir, + _allowedDomains, + _proxyLogsDir, + agentTimeoutMinutes, + ) => { + const manager = this.manager; + const environment = this.environment; + if (!manager || !environment) { + throw new Error('Firecracker microVM is not ready'); + } + if (this.config.tty) { + throw new Error( + 'Firecracker preview guest supervisor does not support TTY execution', + ); + } + + const requestId = `agent-${process.pid}-${Date.now()}`; + const timeoutMs = agentTimeoutMinutes === undefined + ? undefined + : agentTimeoutMinutes * 60_000; + const execution = manager.execute({ + requestId, + argv: ['/bin/bash', '-lc', this.config.agentCommand], + env: environment, + cwd: FIRECRACKER_GUEST_WORKSPACE, + ...this.dependencies.identity(), + tty: false, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + stdout: this.dependencies.stdout, + stderr: this.dependencies.stderr, + }); + this.activeExecution = { requestId, promise: execution }; + + const onData = (chunk: Buffer | string): void => { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + void manager.writeStdin(data, requestId).catch((error) => { + this.dependencies.logger.warn( + `Firecracker guest stdin forwarding failed: ${formatError(error)}`, + ); + void manager.cancel('stdin forwarding failure', requestId).catch(() => undefined); + }); + }; + const onEnd = (): void => { + void manager.endStdin(requestId).catch(() => undefined); + }; + this.dependencies.stdin.on('data', onData); + this.dependencies.stdin.once('end', onEnd); + if (this.dependencies.stdin.readableEnded) onEnd(); + + try { + const result = await execution; + this.dependencies.logger.info( + `[firecracker] Agent command exited with code ${result.exitCode}` + + (result.signal ? ` (${result.signal})` : ''), + ); + return { exitCode: result.exitCode }; + } finally { + this.dependencies.stdin.off('data', onData); + this.dependencies.stdin.off('end', onEnd); + this.activeExecution = undefined; + } }; async collectDiagnostics(): Promise {} - async stop(): Promise {} + async stop(): Promise { + if (this.stopped) return; + if (this.stopping) return this.stopping; + this.stopping = this.stopManager(false); + try { + await this.stopping; + this.stopped = true; + } finally { + this.stopping = undefined; + } + } + + async preserve(): Promise { + if (this.stopped) return; + if (this.stopping) return this.stopping; + this.stopping = this.stopManager(true); + try { + await this.stopping; + this.stopped = true; + if (this.manager) { + this.dependencies.logger.info( + `[firecracker] Preserved jail: ${this.manager.paths.jailRoot}`, + ); + this.dependencies.logger.info( + `[firecracker] Preserved images: ${this.config.workDir}/firecracker-images`, + ); + if (this.manager.networkNamespace) { + this.dependencies.logger.info( + `[firecracker] Preserved network namespace: ${this.manager.networkNamespace}`, + ); + } + } + } finally { + this.stopping = undefined; + } + } + + private async stopManager(preserve: boolean): Promise { + const active = this.activeExecution; + if (active && this.manager) { + try { + await this.manager.cancel('AWF cleanup', active.requestId); + } catch { + // Process termination below remains authoritative. + } + await Promise.race([ + active.promise.catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, FIRECRACKER_CANCEL_GRACE_MS)), + ]); + } + await this.manager?.stop({ preserve }); + } + + private async probeGuestConnectivity(): Promise { + const manager = this.manager!; + const environment = this.environment!; + const squidProbe = + `curl --silent --show-error --max-time 5 --output /dev/null ` + + `http://${SQUID_IP}:3128/`; + const apiProxyProbe = this.config.enableApiProxy + ? ` && curl --fail --silent --show-error --max-time 5 --noproxy '*' ` + + `--output /dev/null http://${API_PROXY_IP}:10000/reflect` + : ''; + const result = await manager.execute({ + requestId: `probe-${process.pid}-${Date.now()}`, + argv: ['/bin/sh', '-c', `set -eu; ${squidProbe}${apiProxyProbe}`], + env: environment, + cwd: FIRECRACKER_GUEST_WORKSPACE, + ...this.dependencies.identity(), + timeoutMs: FIRECRACKER_PROBE_TIMEOUT_MS, + }); + if (result.exitCode !== 0) { + throw new Error( + `Firecracker guest connectivity probe failed with exit code ${result.exitCode}`, + ); + } + this.dependencies.logger.info( + '[firecracker] Guest supervisor, Squid, and API proxy connectivity verified', + ); + } +} + +export function buildFirecrackerGuestEnvironment( + config: WrapperConfig, + infrastructure: Pick, + guestIp = '100.64.0.2', +): Record { + const networkConfig = { + subnet: NETWORK_SUBNET, + squidIp: infrastructure.squidIp, + agentIp: guestIp, + proxyIp: infrastructure.apiProxyIp, + }; + const environment = buildAgentEnvironment({ + config, + networkConfig, + dnsServers: [], + }); + if (config.enableApiProxy) { + Object.assign(environment, buildAgentCredentialEnv({ config, networkConfig })); + } + Object.assign(environment, { + HOME: FIRECRACKER_GUEST_HOME, + PWD: FIRECRACKER_GUEST_WORKSPACE, + AWF_WORKDIR: FIRECRACKER_GUEST_WORKSPACE, + SQUID_PROXY_HOST: infrastructure.squidIp, + HOSTNAME: 'awf-firecracker', + AWF_RUNTIME: 'firecracker', + }); + assertNoProviderSecrets(config, environment); + return environment; +} + +function assertNoProviderSecrets( + config: WrapperConfig, + environment: Readonly>, +): void { + const secrets = [ + config.openaiApiKey, + config.anthropicApiKey, + config.copilotGithubToken, + config.copilotProviderApiKey, + config.geminiApiKey, + config.googleApiKey, + config.githubToken, + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0); + for (const [name, value] of Object.entries(environment)) { + if (secrets.some((secret) => value === secret || value.includes(secret))) { + throw new Error( + `Refusing to pass a real provider credential through Firecracker guest variable ${name}`, + ); + } + } +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); } export function createFirecrackerRuntimeBackend( config: WrapperConfig, startInfrastructure: WorkflowDependencies['startContainers'], ): FirecrackerRuntimeBackend { - return new FirecrackerRuntimeBackend(config, { - startInfrastructure, - preflight: runFirecrackerPreflight, - }); + return new FirecrackerRuntimeBackend(config, defaultDependencies(startInfrastructure)); } diff --git a/src/firecracker/config.test.ts b/src/firecracker/config.test.ts index 90a44a70c..883037d09 100644 --- a/src/firecracker/config.test.ts +++ b/src/firecracker/config.test.ts @@ -63,10 +63,11 @@ describe('Firecracker configuration', () => { jailerBinary: '/opt/jailer', kernelPath: '/opt/vmlinux', rootfsPath: '/opt/rootfs.ext4', + supervisorPath: '/opt/awf-supervisor', vcpuCount: 4, memoryMib: 1024, apiTimeoutMs: 8000, - sha256: { kernel: digest }, + sha256: { kernel: digest, supervisor: digest }, }, }); @@ -76,10 +77,12 @@ describe('Firecracker configuration', () => { firecrackerJailerBinary: '/opt/jailer', firecrackerKernel: '/opt/vmlinux', firecrackerRootfs: '/opt/rootfs.ext4', + firecrackerSupervisor: '/opt/awf-supervisor', firecrackerVcpus: 4, firecrackerMemoryMib: 1024, firecrackerApiTimeoutMs: 8000, firecrackerKernelSha256: digest, + firecrackerSupervisorSha256: digest, })); }); @@ -90,6 +93,7 @@ describe('Firecracker configuration', () => { jailerBinary: FIRECRACKER_DEFAULT_JAILER_BINARY, kernelPath: undefined, rootfsPath: undefined, + supervisorPath: undefined, vcpuCount: FIRECRACKER_DEFAULT_VCPU_COUNT, memoryMib: FIRECRACKER_DEFAULT_MEMORY_MIB, apiTimeoutMs: FIRECRACKER_DEFAULT_API_TIMEOUT_MS, diff --git a/src/firecracker/infrastructure.test.ts b/src/firecracker/infrastructure.test.ts new file mode 100644 index 000000000..946a79e30 --- /dev/null +++ b/src/firecracker/infrastructure.test.ts @@ -0,0 +1,101 @@ +import { + resolveFirecrackerInfrastructure, + type FirecrackerInfrastructureDependencies, +} from './infrastructure'; + +function networkInspection( + overrides: Record = {}, +): Array> { + return [{ + Name: 'awf-net', + Id: 'a'.repeat(64), + Driver: 'bridge', + Scope: 'local', + Internal: true, + Options: {}, + IPAM: { + Config: [{ Subnet: '172.30.0.0/24', Gateway: '172.30.0.1' }], + }, + Containers: { + squid: { Name: 'awf-squid', IPv4Address: '172.30.0.10/24' }, + proxy: { Name: 'awf-api-proxy', IPv4Address: '172.30.0.30/24' }, + }, + ...overrides, + }]; +} + +function dependencies( + inspection: unknown = networkInspection(), +): jest.Mocked { + return { + inspectNetwork: jest.fn().mockResolvedValue(inspection), + inspectLink: jest.fn(async (bridgeName: string) => [{ + ifname: bridgeName, + linkinfo: { info_kind: 'bridge' }, + }]), + }; +} + +describe('Firecracker infrastructure discovery', () => { + it('derives the Docker bridge from the live network ID and revalidates targets', async () => { + const deps = dependencies(); + const resolved = await resolveFirecrackerInfrastructure(true, deps); + + expect(resolved).toEqual(expect.objectContaining({ + networkId: 'a'.repeat(64), + bridgeName: `br-${'a'.repeat(12)}`, + subnet: '172.30.0.0/24', + gateway: '172.30.0.1', + squidIp: '172.30.0.10', + apiProxyIp: '172.30.0.30', + })); + await resolved.revalidate(); + expect(deps.inspectNetwork).toHaveBeenCalledTimes(2); + expect(deps.inspectLink).toHaveBeenCalledWith(`br-${'a'.repeat(12)}`); + }); + + it('rejects ambiguous, non-internal, or address-shifted topology', async () => { + await expect(resolveFirecrackerInfrastructure( + true, + dependencies([networkInspection()[0], networkInspection()[0]]), + )).rejects.toThrow(/exactly one Docker network inspection/); + + await expect(resolveFirecrackerInfrastructure( + true, + dependencies(networkInspection({ Internal: false })), + )).rejects.toThrow(/Unexpected Firecracker infrastructure topology/); + + await expect(resolveFirecrackerInfrastructure( + true, + dependencies(networkInspection({ + Containers: { + squid: { Name: 'awf-squid', IPv4Address: '172.30.0.99/24' }, + proxy: { Name: 'awf-api-proxy', IPv4Address: '172.30.0.30/24' }, + }, + })), + )).rejects.toThrow(/Unexpected "awf-squid" address/); + }); + + it('rejects an accidentally composed primary agent', async () => { + await expect(resolveFirecrackerInfrastructure( + true, + dependencies(networkInspection({ + Containers: { + squid: { Name: 'awf-squid', IPv4Address: '172.30.0.10/24' }, + proxy: { Name: 'awf-api-proxy', IPv4Address: '172.30.0.30/24' }, + agent: { Name: 'awf-agent', IPv4Address: '172.30.0.20/24' }, + }, + })), + )).rejects.toThrow(/Unexpected Compose agent/); + }); + + it('fails when topology changes between resolution and VM setup', async () => { + const deps = dependencies(); + deps.inspectNetwork + .mockResolvedValueOnce(networkInspection()) + .mockResolvedValueOnce(networkInspection({ Id: 'b'.repeat(64) })); + const resolved = await resolveFirecrackerInfrastructure(true, deps); + + await expect(resolved.revalidate()).rejects.toThrow(/topology changed/); + }); +}); diff --git a/src/firecracker/infrastructure.ts b/src/firecracker/infrastructure.ts new file mode 100644 index 000000000..74a7e8986 --- /dev/null +++ b/src/firecracker/infrastructure.ts @@ -0,0 +1,233 @@ +import execa from 'execa'; +import { + API_PROXY_IP, + HOST_GATEWAY, + NETWORK_NAME, + NETWORK_SUBNET, + SQUID_IP, +} from '../config/network-policy'; +import { + AGENT_CONTAINER_NAME, + API_PROXY_CONTAINER_NAME, + SQUID_CONTAINER_NAME, +} from '../constants'; +import { getLocalDockerEnv } from '../docker-host'; + +interface DockerNetworkContainer { + readonly Name?: string; + readonly IPv4Address?: string; +} + +interface DockerNetworkInspection { + readonly Name?: string; + readonly Id?: string; + readonly Driver?: string; + readonly Scope?: string; + readonly Internal?: boolean; + readonly Options?: Readonly>; + readonly IPAM?: { + readonly Config?: ReadonlyArray<{ + readonly Subnet?: string; + readonly Gateway?: string; + }>; + }; + readonly Containers?: Readonly>; +} + +interface IpLinkInspection { + readonly ifname?: string; + readonly linkinfo?: { + readonly info_kind?: string; + }; +} + +export interface FirecrackerInfrastructureSnapshot { + readonly networkId: string; + readonly bridgeName: string; + readonly subnet: string; + readonly gateway: string; + readonly squidIp: string; + readonly apiProxyIp?: string; + revalidate(): Promise; +} + +export interface FirecrackerInfrastructureDependencies { + inspectNetwork(): Promise; + inspectLink(bridgeName: string): Promise; +} + +const defaultDependencies: FirecrackerInfrastructureDependencies = { + inspectNetwork: async () => { + const result = await execa('docker', ['network', 'inspect', NETWORK_NAME], { + env: getLocalDockerEnv(), + reject: false, + timeout: 10_000, + }); + if (result.exitCode !== 0) { + throw new Error( + `Could not inspect Firecracker infrastructure network "${NETWORK_NAME}": ` + + result.stderr.trim(), + ); + } + return JSON.parse(result.stdout) as unknown; + }, + inspectLink: async (bridgeName) => { + const result = await execa('ip', ['-json', '-details', 'link', 'show', 'dev', bridgeName], { + reject: false, + timeout: 5_000, + }); + if (result.exitCode !== 0) { + throw new Error( + `Could not inspect Firecracker infrastructure bridge "${bridgeName}": ` + + result.stderr.trim(), + ); + } + return JSON.parse(result.stdout) as unknown; + }, +}; + +/** + * Resolves and proves the exact host bridge and service addresses used by the + * Compose infrastructure. No default bridge name or daemon-local assumption is + * accepted. + */ +export async function resolveFirecrackerInfrastructure( + enableApiProxy: boolean, + dependencies: FirecrackerInfrastructureDependencies = defaultDependencies, +): Promise { + const resolved = await inspectInfrastructure(enableApiProxy, dependencies); + return { + ...resolved, + revalidate: async () => { + const live = await inspectInfrastructure(enableApiProxy, dependencies); + if ( + live.networkId !== resolved.networkId || + live.bridgeName !== resolved.bridgeName || + live.subnet !== resolved.subnet || + live.gateway !== resolved.gateway || + live.squidIp !== resolved.squidIp || + live.apiProxyIp !== resolved.apiProxyIp + ) { + throw new Error( + `Firecracker infrastructure topology changed after discovery; ` + + `refusing to attach the microVM`, + ); + } + }, + }; +} + +async function inspectInfrastructure( + enableApiProxy: boolean, + dependencies: FirecrackerInfrastructureDependencies, +): Promise> { + const raw = await dependencies.inspectNetwork(); + if (!Array.isArray(raw) || raw.length !== 1) { + throw new Error( + `Expected exactly one Docker network inspection for "${NETWORK_NAME}"`, + ); + } + const network = asRecord(raw[0], 'Docker network') as DockerNetworkInspection; + if ( + network.Name !== NETWORK_NAME || + network.Driver !== 'bridge' || + network.Scope !== 'local' || + network.Internal !== true + ) { + throw new Error( + `Unexpected Firecracker infrastructure topology for "${NETWORK_NAME}": ` + + `name=${String(network.Name)} driver=${String(network.Driver)} ` + + `scope=${String(network.Scope)} internal=${String(network.Internal)}`, + ); + } + if (!network.Id || !/^[a-f0-9]{64}$/i.test(network.Id)) { + throw new Error(`Docker network "${NETWORK_NAME}" returned an invalid network ID`); + } + + const ipv4Configs = (network.IPAM?.Config ?? []).filter((entry) => entry.Subnet?.includes('.')); + if ( + ipv4Configs.length !== 1 || + ipv4Configs[0].Subnet !== NETWORK_SUBNET || + ipv4Configs[0].Gateway !== HOST_GATEWAY + ) { + throw new Error( + `Docker network "${NETWORK_NAME}" must have exactly ${NETWORK_SUBNET} ` + + `with gateway ${HOST_GATEWAY}`, + ); + } + + const configuredBridge = + network.Options?.['com.docker.network.bridge.name']; + const bridgeName = configuredBridge || `br-${network.Id.slice(0, 12)}`; + assertInterfaceName(bridgeName); + const rawLinks = await dependencies.inspectLink(bridgeName); + if (!Array.isArray(rawLinks) || rawLinks.length !== 1) { + throw new Error(`Expected exactly one host bridge named "${bridgeName}"`); + } + const link = asRecord(rawLinks[0], 'host bridge') as IpLinkInspection; + if (link.ifname !== bridgeName || link.linkinfo?.info_kind !== 'bridge') { + throw new Error(`Host interface "${bridgeName}" is not the Docker bridge for "${NETWORK_NAME}"`); + } + + const containers = Object.values(network.Containers ?? {}); + assertContainerAbsent(containers, AGENT_CONTAINER_NAME); + const squidIp = resolveContainerIp(containers, SQUID_CONTAINER_NAME, SQUID_IP); + const apiProxyIp = enableApiProxy + ? resolveContainerIp(containers, API_PROXY_CONTAINER_NAME, API_PROXY_IP) + : undefined; + + return { + networkId: network.Id, + bridgeName, + subnet: NETWORK_SUBNET, + gateway: HOST_GATEWAY, + squidIp, + ...(apiProxyIp ? { apiProxyIp } : {}), + }; +} + +function resolveContainerIp( + containers: readonly DockerNetworkContainer[], + name: string, + expectedIp: string, +): string { + const matches = containers.filter((container) => container.Name === name); + if (matches.length !== 1) { + throw new Error( + `Expected exactly one "${name}" endpoint on "${NETWORK_NAME}", found ${matches.length}`, + ); + } + const address = matches[0].IPv4Address; + const ip = address?.split('/')[0]; + if (ip !== expectedIp) { + throw new Error( + `Unexpected "${name}" address on "${NETWORK_NAME}": ` + + `expected ${expectedIp}, found ${String(address)}`, + ); + } + return ip; +} + +function assertContainerAbsent( + containers: readonly DockerNetworkContainer[], + name: string, +): void { + if (containers.some((container) => container.Name === name)) { + throw new Error( + `Unexpected Compose agent "${name}" is attached during Firecracker execution`, + ); + } +} + +function assertInterfaceName(name: string): void { + if (name.length < 1 || name.length > 15 || !/^[A-Za-z0-9_.-]+$/.test(name)) { + throw new Error(`Unsafe Firecracker infrastructure bridge name: ${name}`); + } +} + +function asRecord(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${label} inspection is not an object`); + } + return value as Record; +} diff --git a/src/firecracker/manager.test.ts b/src/firecracker/manager.test.ts index a9d3993e8..75dc8be8f 100644 --- a/src/firecracker/manager.test.ts +++ b/src/firecracker/manager.test.ts @@ -354,6 +354,54 @@ describe('FirecrackerManager', () => { expect(order).toEqual(['extract']); }); + it('quiesces and copies back while preserving jail, images, and network in keep mode', async () => { + const child = processMock(); + const workspace = { + prepare: jest.fn().mockResolvedValue({ + workspaceImagePath: '/tmp/prepared-workspace.ext4', + rootfsImagePath: '/tmp/prepared-rootfs.ext4', + imageBytes: 1024, + originalManifest: new Map(), + }), + extractAfterStop: jest.fn().mockResolvedValue(undefined), + cleanup: jest.fn().mockResolvedValue(undefined), + } as unknown as FirecrackerWorkspaceImage; + const guestClient = { + connect: jest.fn().mockResolvedValue(undefined), + shutdown: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + } as unknown as FirecrackerVsockClient; + const deps = dependencies({ + launch: jest.fn().mockReturnValue(child), + createWorkspaceImage: jest.fn().mockReturnValue(workspace), + createVsockClient: jest.fn().mockReturnValue(guestClient), + }); + const manager = new FirecrackerManager( + config(), + '/tmp/awf', + deps, + 'keep', + networkConfig(), + { + workspacePath: '/workspace', + homePath: '/home/runner', + supervisorBinaryPath: '/opt/awf-supervisor', + supervisorSha256: 'a'.repeat(64), + }, + ); + await manager.start(); + await manager.startInstance(); + + await manager.stop({ preserve: true }); + + const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0] + .value as FirecrackerNetworkLifecycle; + expect(workspace.extractAfterStop).toHaveBeenCalledTimes(1); + expect(lifecycle.cleanup).not.toHaveBeenCalled(); + expect(workspace.cleanup).not.toHaveBeenCalled(); + expect(deps.rm).not.toHaveBeenCalled(); + }); + it('builds explicit supervisor boot networking without widening policy', () => { const args = buildSupervisorBootArgs({ runId: 'run', diff --git a/src/firecracker/manager.ts b/src/firecracker/manager.ts index e4faf6882..c66513677 100644 --- a/src/firecracker/manager.ts +++ b/src/firecracker/manager.ts @@ -158,8 +158,17 @@ export class FirecrackerManager { private network: FirecrackerNetworkLifecycle | undefined; private workspace: FirecrackerWorkspaceImage | undefined; private guestClient: FirecrackerVsockClient | undefined; + private networkPlan: FirecrackerNetworkPlan | undefined; private instanceStarted = false; + get guestIp(): string | undefined { + return this.networkPlan?.guestIp; + } + + get networkNamespace(): string | undefined { + return this.networkPlan?.namespaceName; + } + constructor( private readonly config: FirecrackerOptions, private readonly workDir: string, @@ -187,6 +196,7 @@ export class FirecrackerManager { jailerUid: identity.uid, jailerGid: identity.gid, }); + this.networkPlan = networkPlan; this.network = this.dependencies.createNetwork(networkPlan); await this.network.setup(); let rootfsSource = artifacts.rootfsPath; @@ -313,7 +323,35 @@ export class FirecrackerManager { return this.guestClient.execute(request); } - async stop(): Promise { + cancel(reason = 'host cancellation', requestId?: string): Promise { + if (!this.guestClient) { + return Promise.reject(new Error('Firecracker guest supervisor is not ready')); + } + return this.guestClient.cancel(reason, requestId); + } + + writeStdin(data: Buffer, requestId?: string): Promise { + if (!this.guestClient) { + return Promise.reject(new Error('Firecracker guest supervisor is not ready')); + } + return this.guestClient.writeStdin(data, requestId); + } + + endStdin(requestId?: string): Promise { + if (!this.guestClient) { + return Promise.reject(new Error('Firecracker guest supervisor is not ready')); + } + return this.guestClient.endStdin(requestId); + } + + resize(columns: number, rows: number, requestId?: string): Promise { + if (!this.guestClient) { + return Promise.reject(new Error('Firecracker guest supervisor is not ready')); + } + return this.guestClient.resize(columns, rows, requestId); + } + + async stop(options: { preserve?: boolean } = {}): Promise { const errors: unknown[] = []; const instanceWasStarted = this.instanceStarted; let guestShutdownAcknowledged = false; @@ -322,7 +360,12 @@ export class FirecrackerManager { await this.guestClient.shutdown(); guestShutdownAcknowledged = true; } catch (error) { - errors.push(error); + if ( + !(error instanceof Error) || + error.message !== 'Cannot shut down Firecracker guest while a request is running' + ) { + errors.push(error); + } this.guestClient.destroy(); } } @@ -382,9 +425,20 @@ export class FirecrackerManager { } this.instanceStarted = false; + if (options.preserve) { + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new Error( + `Firecracker preservation failed: ${errors.map(formatError).join('; ')}`, + ); + } + return; + } + try { await this.network?.cleanup(); this.network = undefined; + this.networkPlan = undefined; } catch (error) { errors.push(error); } diff --git a/src/firecracker/preflight.test.ts b/src/firecracker/preflight.test.ts index d3de4243c..452de75bf 100644 --- a/src/firecracker/preflight.test.ts +++ b/src/firecracker/preflight.test.ts @@ -15,6 +15,7 @@ function config(overrides: Partial = {}): FirecrackerOptions jailerBinary: '/opt/jailer', kernelPath: '/opt/vmlinux', rootfsPath: '/opt/rootfs.ext4', + supervisorPath: '/opt/awf-supervisor', vcpuCount: 2, memoryMib: 512, apiTimeoutMs: 5000, @@ -38,6 +39,7 @@ function dependencies( }), runVersion: jest.fn().mockResolvedValue('Firecracker v1.16.1'), sha256: jest.fn().mockResolvedValue(digest), + assertToolAvailable: jest.fn().mockResolvedValue(undefined), ...overrides, }; } @@ -61,6 +63,7 @@ describe('Firecracker preflight', () => { jailer: digest, kernel: digest, rootfs: digest, + supervisor: digest, }, }), deps); @@ -69,7 +72,8 @@ describe('Firecracker preflight', () => { '/dev/kvm', constants.R_OK | constants.W_OK, ); - expect(deps.sha256).toHaveBeenCalledTimes(4); + expect(deps.sha256).toHaveBeenCalledTimes(5); + expect(deps.assertToolAvailable).toHaveBeenCalledTimes(6); }); it('rejects inaccessible KVM without checking artifacts', async () => { diff --git a/src/firecracker/preflight.ts b/src/firecracker/preflight.ts index 493f401a7..a4b8bbbf0 100644 --- a/src/firecracker/preflight.ts +++ b/src/firecracker/preflight.ts @@ -20,6 +20,7 @@ export interface FirecrackerPreflightDependencies { }>; runVersion(binaryPath: string): Promise; sha256(filePath: string): Promise; + assertToolAvailable(tool: string): Promise; } const defaultDependencies: FirecrackerPreflightDependencies = { @@ -42,6 +43,19 @@ const defaultDependencies: FirecrackerPreflightDependencies = { return `${result.stdout}\n${result.stderr}`.trim(); }, sha256: calculateSha256, + assertToolAvailable: async (tool) => { + const searchPath = process.env.PATH ?? ''; + for (const directory of searchPath.split(path.delimiter)) { + if (!directory) continue; + try { + await fs.access(path.join(directory, tool), constants.X_OK); + return; + } catch { + // Continue searching the bounded host PATH. + } + } + throw new Error(`required host tool "${tool}" was not found on PATH`); + }, }; export interface FirecrackerPreflightResult { @@ -50,6 +64,7 @@ export interface FirecrackerPreflightResult { jailerBinary: string; kernelPath: string; rootfsPath: string; + supervisorPath: string; } export function parseFirecrackerVersion(output: string): string { @@ -178,8 +193,10 @@ export async function runFirecrackerPreflight( `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'); + if (!config.kernelPath || !config.rootfsPath || !config.supervisorPath) { + throw new Error( + 'Firecracker requires guest kernel, rootfs, and supervisor artifact paths', + ); } try { @@ -196,6 +213,17 @@ export async function runFirecrackerPreflight( constants.R_OK | constants.X_OK, dependencies, ); + + for (const tool of ['ip', 'nft', 'mke2fs', 'debugfs', 'e2fsck', 'rsync']) { + try { + await dependencies.assertToolAvailable(tool); + } catch (error) { + throw new Error( + `Firecracker requires host tool "${tool}": ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + } await assertTrustedRegularFile( 'Firecracker jailer binary', config.jailerBinary, @@ -214,6 +242,12 @@ export async function runFirecrackerPreflight( constants.R_OK, dependencies, ); + await assertTrustedRegularFile( + 'Firecracker guest supervisor', + config.supervisorPath, + constants.R_OK, + dependencies, + ); const firecrackerVersion = parseFirecrackerVersion( await dependencies.runVersion(config.firecrackerBinary), @@ -256,6 +290,12 @@ export async function runFirecrackerPreflight( config.sha256?.rootfs, dependencies, ); + await assertDigest( + 'Firecracker guest supervisor', + config.supervisorPath, + config.sha256?.supervisor, + dependencies, + ); return { version: firecrackerVersion, @@ -263,5 +303,6 @@ export async function runFirecrackerPreflight( jailerBinary: config.jailerBinary, kernelPath: config.kernelPath, rootfsPath: config.rootfsPath, + supervisorPath: config.supervisorPath, }; } diff --git a/src/firecracker/runtime-validation.ts b/src/firecracker/runtime-validation.ts new file mode 100644 index 000000000..3ca237b36 --- /dev/null +++ b/src/firecracker/runtime-validation.ts @@ -0,0 +1,92 @@ +import { getLocalDockerEnv } from '../docker-host'; +import type { FirecrackerOptions, WrapperConfig } from '../types'; + +export function assertFirecrackerSelection(config: WrapperConfig): void { + if (config.firecracker && config.containerRuntime !== 'firecracker') { + throw new Error( + 'Firecracker options require --container-runtime firecracker', + ); + } +} + +export function assertFirecrackerRuntimeCompatibility( + config: WrapperConfig, + firecracker = requireFirecrackerConfig(config), +): void { + if (!firecracker.previewEnabled) { + throw new Error( + 'Firecracker workload execution requires explicit --firecracker-preview opt-in', + ); + } + if (!config.networkIsolation || config.legacySecurity) { + throw new Error('Firecracker preview requires strict --network-isolation security'); + } + if (!config.enableApiProxy) { + throw new Error('Firecracker preview requires API proxy credential isolation'); + } + assertFirecrackerPreSecurityCompatibility(config); + if (!firecracker.kernelPath || !firecracker.rootfsPath || !firecracker.supervisorPath) { + throw new Error( + 'Firecracker preview requires explicit kernel, rootfs, and guest supervisor artifacts', + ); + } + const digests = firecracker.sha256; + if ( + !digests?.firecracker || + !digests.jailer || + !digests.kernel || + !digests.rootfs || + !digests.supervisor + ) { + throw new Error( + 'Firecracker preview requires SHA-256 digests for firecracker, jailer, kernel, rootfs, and supervisor', + ); + } +} + +export function assertFirecrackerPreSecurityCompatibility(config: WrapperConfig): void { + if (config.networkIsolation === false) { + throw new Error('Firecracker preview cannot disable --network-isolation'); + } + if ( + config.enableDind || + config.dockerHostPathPrefix || + config.runnerTopology === 'arc-dind' + ) { + throw new Error('Firecracker preview does not support Docker-in-Docker or split filesystems'); + } + if (config.enableHostAccess || config.allowHostPorts || config.allowHostServicePorts) { + throw new Error('Firecracker preview does not support host access'); + } + if (config.volumeMounts?.length) { + throw new Error('Firecracker preview does not support additional host volume mounts'); + } + if ( + config.topologyAttach?.length || + config.difcProxyHost || + config.enclaves?.enabled + ) { + throw new Error( + 'Firecracker preview does not yet prove the MCP gateway path; topology peers and enclaves are disabled', + ); + } + if (config.dnsOverHttps) { + throw new Error('Firecracker preview does not support DNS-over-HTTPS'); + } + if (config.tty) { + throw new Error('Firecracker preview guest supervisor does not support --tty'); + } + const dockerHost = config.awfDockerHost ?? getLocalDockerEnv().DOCKER_HOST; + if (dockerHost && !dockerHost.startsWith('unix://')) { + throw new Error( + 'Firecracker preview requires a local Unix-socket Docker daemon so its bridge is host-visible', + ); + } +} + +export function requireFirecrackerConfig(config: WrapperConfig): FirecrackerOptions { + if (config.containerRuntime !== 'firecracker' || !config.firecracker) { + throw new Error('Firecracker backend resolved without Firecracker runtime configuration'); + } + return config.firecracker; +} diff --git a/src/types/runtime-options.ts b/src/types/runtime-options.ts index dc25e3f40..ad26e6a9a 100644 --- a/src/types/runtime-options.ts +++ b/src/types/runtime-options.ts @@ -16,14 +16,14 @@ export interface FirecrackerArtifactDigests { jailer?: string; kernel?: string; rootfs?: string; + supervisor?: string; } /** - * Preview control-plane configuration for the Firecracker microVM runtime. + * Preview workload configuration for the Firecracker microVM runtime. * * Host-side network enforcement and guest execution inputs are supplied - * directly to FirecrackerManager after infrastructure discovery. Global - * runtime dispatch remains fail-closed until all integration probes are wired. + * directly to FirecrackerManager after live infrastructure discovery. */ export interface FirecrackerOptions { previewEnabled: boolean; @@ -31,6 +31,7 @@ export interface FirecrackerOptions { jailerBinary: string; kernelPath?: string; rootfsPath?: string; + supervisorPath?: string; vcpuCount: number; memoryMib: number; apiTimeoutMs: number; From 9f55c9fc203c7d2e7160050d134a6a34f754dcd1 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 9 Aug 2026 14:16:33 -0700 Subject: [PATCH 2/4] test: cover Firecracker runtime integration Exercise layer-5 host probes, policy validation, backend lifecycle, manager delegation, and external runtime cleanup without changing coverage thresholds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/commands/main-action.test.ts | 51 +++++++ src/commands/main-action.ts | 13 +- src/commands/validate-options.test.ts | 69 ++++++++++ src/external-runtime-backend.test.ts | 9 ++ src/firecracker-runtime-backend.test.ts | 152 +++++++++++++++++++++ src/firecracker-runtime-backend.ts | 3 + src/firecracker/infrastructure.test.ts | 120 ++++++++++++++++ src/firecracker/manager.test.ts | 99 ++++++++++++++ src/firecracker/manager.ts | 6 + src/firecracker/preflight.test.ts | 113 +++++++++++++++ src/firecracker/preflight.ts | 3 + src/firecracker/runtime-validation.test.ts | 106 ++++++++++++++ 12 files changed, 734 insertions(+), 10 deletions(-) create mode 100644 src/firecracker/runtime-validation.test.ts diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index 010dd99c1..493b5fdc8 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -457,6 +457,57 @@ describe('createMainAction', () => { expect.stringContaining('Configuration files preserved') ); }); + + it('quiesces an external runtime through its preserve hook', async () => { + const preserve = jest.fn().mockResolvedValue(undefined); + const backend = { + runtime: 'firecracker', + preflight: jest.fn(), + start: jest.fn(), + exec: jest.fn(), + collectDiagnostics: jest.fn(), + stop: jest.fn(), + preserve, + }; + const cleanup = testHelpers.buildCleanupFn( + { ...MAIN_ACTION_STUB_CONFIG, keepContainers: true }, + () => false, + () => false, + backend, + ); + + await cleanup(); + + expect(preserve).toHaveBeenCalledTimes(1); + expect(backend.stop).not.toHaveBeenCalled(); + }); + }); + + describe('external runtime cleanup failures', () => { + it('continues generic cleanup and then rethrows the runtime failure', async () => { + const runtimeError = new Error('Firecracker teardown failed'); + const backend = { + runtime: 'firecracker', + preflight: jest.fn(), + start: jest.fn(), + exec: jest.fn(), + collectDiagnostics: jest.fn(), + stop: jest.fn().mockRejectedValue(runtimeError), + }; + const cleanup = testHelpers.buildCleanupFn( + { ...MAIN_ACTION_STUB_CONFIG, keepContainers: false }, + () => false, + () => false, + backend, + ); + + await expect(cleanup()).rejects.toBe(runtimeError); + expect(mockedDockerManager.cleanup).toHaveBeenCalled(); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'External runtime cleanup failed; continuing with infrastructure teardown.', + runtimeError, + ); + }); }); describe('performCleanup with containers started', () => { diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index f4163fb33..ff89195da 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -99,7 +99,7 @@ function buildCleanupFn( externalRuntimeBackend?: ExternalAgentRuntimeBackend, ) { return async (signal?: string) => { - const cleanupErrors: unknown[] = []; + let externalRuntimeCleanupError: unknown; if (signal) { logger.info(`Received ${signal}, cleaning up...`); } @@ -112,7 +112,7 @@ function buildCleanupFn( await externalRuntimeBackend.stop(); } } catch (error) { - cleanupErrors.push(error); + externalRuntimeCleanupError = error; logger.warn( 'External runtime cleanup failed; continuing with infrastructure teardown.', error, @@ -182,14 +182,7 @@ function buildCleanupFn( logger.info(`Squid logs available at: ${config.workDir}/squid-logs/`); logger.info(`Host iptables rules preserved (--keep-containers enabled)`); } - if (cleanupErrors.length === 1) throw cleanupErrors[0]; - if (cleanupErrors.length > 1) { - throw new Error( - `Cleanup failed: ${cleanupErrors.map((error) => ( - error instanceof Error ? error.message : String(error) - )).join('; ')}`, - ); - } + if (externalRuntimeCleanupError) throw externalRuntimeCleanupError; }; } diff --git a/src/commands/validate-options.test.ts b/src/commands/validate-options.test.ts index d3c642b83..7940c3473 100644 --- a/src/commands/validate-options.test.ts +++ b/src/commands/validate-options.test.ts @@ -472,6 +472,75 @@ describe('validateOptions', () => { // Post-config validations (docker host, rate limits, feature flags, ports) // --------------------------------------------------------------------------- + describe('Firecracker runtime validation', () => { + const digest = 'a'.repeat(64); + const firecracker = { + previewEnabled: true, + firecrackerBinary: '/opt/firecracker', + jailerBinary: '/opt/jailer', + kernelPath: '/opt/kernel', + rootfsPath: '/opt/rootfs', + supervisorPath: '/opt/supervisor', + vcpuCount: 2, + memoryMib: 512, + apiTimeoutMs: 5000, + sha256: { + firecracker: digest, + jailer: digest, + kernel: digest, + rootfs: digest, + supervisor: digest, + }, + }; + + function firecrackerConfig(overrides: Record = {}) { + return { + ...STUB_CONFIG, + containerRuntime: 'firecracker', + legacySecurity: false, + networkIsolation: undefined, + enableApiProxy: undefined, + firecracker, + ...overrides, + }; + } + + it('accepts a complete strict preview configuration', () => { + mockedBuildConfig.buildConfig.mockReturnValue(firecrackerConfig()); + expect(() => validateOptions(validOptions(), 'echo hi')).not.toThrow(); + }); + + it('rejects Firecracker options for another runtime', () => { + mockedBuildConfig.buildConfig.mockReturnValue(firecrackerConfig({ + containerRuntime: 'gvisor', + })); + expect(() => validateOptions(validOptions(), 'echo hi')).toThrow('process.exit called'); + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Firecracker options require'), + ); + }); + + it('rejects unsupported Firecracker policy before strict-mode coercion', () => { + mockedBuildConfig.buildConfig.mockReturnValue(firecrackerConfig({ + enableDind: true, + })); + expect(() => validateOptions(validOptions(), 'echo hi')).toThrow('process.exit called'); + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('does not support Docker-in-Docker'), + ); + }); + + it('rejects an incomplete Firecracker runtime after security defaults', () => { + mockedBuildConfig.buildConfig.mockReturnValue(firecrackerConfig({ + firecracker: { ...firecracker, previewEnabled: false }, + })); + expect(() => validateOptions(validOptions(), 'echo hi')).toThrow('process.exit called'); + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('requires explicit --firecracker-preview'), + ); + }); + }); + describe('--docker-host validation', () => { it('exits when --docker-host is not a unix:// URI', () => { mockedBuildConfig.buildConfig.mockReturnValue({ diff --git a/src/external-runtime-backend.test.ts b/src/external-runtime-backend.test.ts index 5f7698012..bd940000e 100644 --- a/src/external-runtime-backend.test.ts +++ b/src/external-runtime-backend.test.ts @@ -68,6 +68,15 @@ describe('external runtime backend', () => { .toThrow(/explicit --firecracker-preview/); expect(startInfrastructure).not.toHaveBeenCalled(); }); + + it('uses the registered Firecracker factory after preview opt-in', () => { + const backend = resolveExternalRuntimeBackend({ + containerRuntime: 'firecracker', + firecracker: { previewEnabled: true }, + } as WrapperConfig, startInfrastructure); + + expect(backend?.runtime).toBe('firecracker'); + }); 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.test.ts b/src/firecracker-runtime-backend.test.ts index cc69df060..b748ee247 100644 --- a/src/firecracker-runtime-backend.test.ts +++ b/src/firecracker-runtime-backend.test.ts @@ -4,6 +4,8 @@ import { FirecrackerRuntimeBackend, assertFirecrackerPreSecurityCompatibility, buildFirecrackerGuestEnvironment, + createFirecrackerRuntimeBackend, + firecrackerRuntimeTestHelpers, type FirecrackerRuntimeBackendDependencies, } from './firecracker-runtime-backend'; import { assertFirecrackerSelection } from './firecracker/runtime-validation'; @@ -117,6 +119,35 @@ function harness(overrides: Partial = {}) } describe('Firecracker runtime backend', () => { + it('constructs default backend dependencies and manager policy', () => { + const startInfrastructure = jest.fn(); + const defaults = firecrackerRuntimeTestHelpers.defaultDependencies(startInfrastructure); + const previousWorkspace = process.env.GITHUB_WORKSPACE; + process.env.GITHUB_WORKSPACE = '/github/workspace'; + try { + expect(defaults.workspacePath()).toBe('/github/workspace'); + delete process.env.GITHUB_WORKSPACE; + expect(defaults.workspacePath()).toBe(process.cwd()); + expect(defaults.homePath()).toBeTruthy(); + expect(defaults.identity()).toEqual({ + uid: expect.any(Number), + gid: expect.any(Number), + }); + expect(defaults.createManager( + config().firecracker!, + '/tmp/awf', + infrastructure(), + '/workspace', + '/home/runner', + )).toBeDefined(); + expect(createFirecrackerRuntimeBackend(config(), startInfrastructure)) + .toBeInstanceOf(FirecrackerRuntimeBackend); + } finally { + if (previousWorkspace === undefined) delete process.env.GITHUB_WORKSPACE; + else process.env.GITHUB_WORKSPACE = previousWorkspace; + } + }); + it('starts infrastructure, revalidates it, boots and probes before execution', async () => { const { order, manager, deps, stdin } = harness(); const backend = new FirecrackerRuntimeBackend(config(), deps); @@ -164,6 +195,119 @@ describe('Firecracker runtime backend', () => { expect(manager.stop).toHaveBeenCalledTimes(1); }); + it('fails closed when manager readiness or startup cleanup is unavailable', async () => { + const missingIp = harness(); + Reflect.set(missingIp.manager, 'guestIp', undefined); + const backend = new FirecrackerRuntimeBackend(config(), missingIp.deps); + await expect(backend.start('/tmp/awf', ['github.com'])) + .rejects.toThrow(/did not expose the configured guest IP/); + expect(missingIp.manager.stop).toHaveBeenCalledTimes(1); + + const dualFailure = harness(); + (dualFailure.infra.revalidate as jest.Mock).mockRejectedValue('topology moved'); + dualFailure.manager.stop.mockRejectedValue('cleanup failed'); + const failing = new FirecrackerRuntimeBackend(config(), dualFailure.deps); + await expect(failing.start('/tmp/awf', ['github.com'])).rejects.toMatchObject({ + message: expect.stringContaining('topology moved'), + cause: 'topology moved', + cleanupCause: 'cleanup failed', + }); + }); + + it('rejects execution before readiness and unsupported TTY execution', async () => { + const cold = harness(); + await expect(new FirecrackerRuntimeBackend(config(), cold.deps).exec( + '/tmp/awf', + ['github.com'], + )).rejects.toThrow(/microVM is not ready/); + + const ttyHarness = harness(); + const ttyConfig = config(); + const ttyBackend = new FirecrackerRuntimeBackend(ttyConfig, ttyHarness.deps); + await ttyBackend.start('/tmp/awf', ['github.com']); + ttyConfig.tty = true; + await expect(ttyBackend.exec('/tmp/awf', ['github.com'])) + .rejects.toThrow(/does not support TTY execution/); + await ttyBackend.stop(); + }); + + it('preserves a stopped VM once and logs retained artifacts', async () => { + const { manager, deps } = harness(); + const backend = new FirecrackerRuntimeBackend(config(), deps); + await backend.start('/tmp/awf', ['github.com']); + + await backend.preserve(); + await backend.preserve(); + await backend.collectDiagnostics(); + + expect(manager.stop).toHaveBeenCalledWith({ preserve: true }); + expect(manager.stop).toHaveBeenCalledTimes(1); + expect(deps.logger.info).toHaveBeenCalledWith( + '[firecracker] Preserved network namespace: awffc-test', + ); + }); + + it('cancels an active guest command before stopping', async () => { + const { manager, deps } = harness(); + let resolveExecution!: (value: { + requestId: string; + exitCode: number; + signal: null; + timedOut: boolean; + }) => void; + manager.execute + .mockReset() + .mockResolvedValueOnce({ + requestId: 'probe', + exitCode: 0, + signal: null, + timedOut: false, + }) + .mockReturnValueOnce(new Promise((resolve) => { + resolveExecution = resolve; + })); + manager.cancel.mockImplementationOnce(async () => { + resolveExecution({ + requestId: 'agent', + exitCode: 130, + signal: null, + timedOut: false, + }); + }); + const backend = new FirecrackerRuntimeBackend(config(), deps); + await backend.start('/tmp/awf', ['github.com']); + const execution = backend.exec('/tmp/awf', ['github.com']); + + await backend.stop(); + await expect(execution).resolves.toEqual({ exitCode: 130 }); + await backend.stop(); + expect(manager.cancel).toHaveBeenCalledWith( + 'AWF cleanup', + expect.stringMatching(/^agent-/), + ); + expect(manager.stop).toHaveBeenCalledWith({ preserve: false }); + }); + + it('cancels after stdin forwarding failure without changing command output', async () => { + const { manager, deps, stdin } = harness(); + manager.writeStdin.mockRejectedValueOnce(new Error('closed stdin')); + const backend = new FirecrackerRuntimeBackend(config(), deps); + await backend.start('/tmp/awf', ['github.com']); + const execution = backend.exec('/tmp/awf', ['github.com']); + stdin.write('input'); + await new Promise((resolve) => setImmediate(resolve)); + + await expect(execution).resolves.toEqual({ exitCode: 23 }); + expect(deps.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('stdin forwarding failed'), + ); + expect(manager.cancel).toHaveBeenCalledWith( + 'stdin forwarding failure', + expect.stringMatching(/^agent-/), + ); + await backend.stop(); + }); + it('preserves sanitized env values without leaking real provider secrets', () => { const secret = 'sk-real-provider-secret'; const environment = buildFirecrackerGuestEnvironment( @@ -182,6 +326,14 @@ describe('Firecracker runtime backend', () => { expect(Object.values(environment)).not.toContain(secret); expect(environment.HTTP_PROXY).toBe('http://172.30.0.10:3128'); expect(environment.HOME).toBe('/workspace/.awf-home'); + + expect(() => buildFirecrackerGuestEnvironment( + config({ + openaiApiKey: 'enabled', + additionalEnv: { SAFE_SETTING: 'enabled' }, + }), + infrastructure(), + )).toThrow(/Refusing to pass a real provider credential/); }); it('rejects unsupported strict-security and topology combinations', () => { diff --git a/src/firecracker-runtime-backend.ts b/src/firecracker-runtime-backend.ts index 073a134ba..5fcffb60a 100644 --- a/src/firecracker-runtime-backend.ts +++ b/src/firecracker-runtime-backend.ts @@ -111,6 +111,9 @@ function defaultDependencies( }; } +/** @internal Exposed only for focused default-policy tests. */ +export const firecrackerRuntimeTestHelpers = { defaultDependencies }; + /** Stateful adapter for an explicitly enabled, fail-closed Firecracker microVM. */ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { readonly runtime = 'firecracker'; diff --git a/src/firecracker/infrastructure.test.ts b/src/firecracker/infrastructure.test.ts index 946a79e30..6d2ebfb6b 100644 --- a/src/firecracker/infrastructure.test.ts +++ b/src/firecracker/infrastructure.test.ts @@ -2,6 +2,11 @@ import { resolveFirecrackerInfrastructure, type FirecrackerInfrastructureDependencies, } from './infrastructure'; +import execa from 'execa'; + +jest.mock('execa'); + +const mockedExeca = execa as jest.MockedFunction; function networkInspection( overrides: Record = {}, @@ -37,6 +42,67 @@ function dependencies( } describe('Firecracker infrastructure discovery', () => { + beforeEach(() => { + mockedExeca.mockReset(); + }); + + it('uses the default Docker and host-link probes', async () => { + mockedExeca + .mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify(networkInspection()), + stderr: '', + } as never) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify([{ + ifname: `br-${'a'.repeat(12)}`, + linkinfo: { info_kind: 'bridge' }, + }]), + stderr: '', + } as never); + + await expect(resolveFirecrackerInfrastructure(true)).resolves.toEqual( + expect.objectContaining({ squidIp: '172.30.0.10', apiProxyIp: '172.30.0.30' }), + ); + expect(mockedExeca).toHaveBeenNthCalledWith( + 1, + 'docker', + ['network', 'inspect', 'awf-net'], + expect.objectContaining({ reject: false, timeout: 10_000 }), + ); + expect(mockedExeca).toHaveBeenNthCalledWith( + 2, + 'ip', + ['-json', '-details', 'link', 'show', 'dev', `br-${'a'.repeat(12)}`], + { reject: false, timeout: 5_000 }, + ); + }); + + it('surfaces default Docker and link probe failures', async () => { + mockedExeca.mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'network unavailable', + } as never); + await expect(resolveFirecrackerInfrastructure(true)) + .rejects.toThrow(/Could not inspect.*network unavailable/); + + mockedExeca + .mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify(networkInspection()), + stderr: '', + } as never) + .mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'link unavailable', + } as never); + await expect(resolveFirecrackerInfrastructure(true)) + .rejects.toThrow(/Could not inspect.*bridge.*link unavailable/); + }); + it('derives the Docker bridge from the live network ID and revalidates targets', async () => { const deps = dependencies(); const resolved = await resolveFirecrackerInfrastructure(true, deps); @@ -74,6 +140,60 @@ describe('Firecracker infrastructure discovery', () => { }, })), )).rejects.toThrow(/Unexpected "awf-squid" address/); + + await expect(resolveFirecrackerInfrastructure( + true, + dependencies(networkInspection({ Id: 'invalid' })), + )).rejects.toThrow(/invalid network ID/); + + await expect(resolveFirecrackerInfrastructure( + true, + dependencies(networkInspection({ IPAM: { Config: [] } })), + )).rejects.toThrow(/must have exactly 172\.30\.0\.0\/24/); + }); + + it('validates the bridge and required service endpoint shape', async () => { + const badLink = dependencies(); + badLink.inspectLink.mockResolvedValue([]); + await expect(resolveFirecrackerInfrastructure(true, badLink)) + .rejects.toThrow(/exactly one host bridge/); + + const nonBridge = dependencies(); + nonBridge.inspectLink.mockResolvedValue([{ + ifname: `br-${'a'.repeat(12)}`, + linkinfo: { info_kind: 'veth' }, + }]); + await expect(resolveFirecrackerInfrastructure(true, nonBridge)) + .rejects.toThrow(/is not the Docker bridge/); + + await expect(resolveFirecrackerInfrastructure( + true, + dependencies(networkInspection({ Containers: {} })), + )).rejects.toThrow(/Expected exactly one "awf-squid" endpoint/); + + await expect(resolveFirecrackerInfrastructure( + true, + dependencies(networkInspection({ + Options: { 'com.docker.network.bridge.name': 'unsafe bridge' }, + })), + )).rejects.toThrow(/Unsafe Firecracker infrastructure bridge name/); + + await expect(resolveFirecrackerInfrastructure( + true, + dependencies([null]), + )).rejects.toThrow(/Docker network inspection is not an object/); + }); + + it('supports Squid-only infrastructure without an API proxy', async () => { + const resolved = await resolveFirecrackerInfrastructure( + false, + dependencies(networkInspection({ + Containers: { + squid: { Name: 'awf-squid', IPv4Address: '172.30.0.10/24' }, + }, + })), + ); + expect(resolved.apiProxyIp).toBeUndefined(); }); it('rejects an accidentally composed primary agent', async () => { diff --git a/src/firecracker/manager.test.ts b/src/firecracker/manager.test.ts index 75dc8be8f..146ccf76d 100644 --- a/src/firecracker/manager.test.ts +++ b/src/firecracker/manager.test.ts @@ -5,6 +5,7 @@ import { FirecrackerManager, buildSupervisorBootArgs, createFirecrackerRunPaths, + firecrackerManagerTestHelpers, type FirecrackerManagerDependencies, type FirecrackerManagerNetworkConfig, } from './manager'; @@ -98,6 +99,40 @@ function dependencies( } describe('FirecrackerManager', () => { + it('constructs the default host adapters and jailer identity', async () => { + const defaults = firecrackerManagerTestHelpers.defaultDependencies; + const child = defaults.launch(process.execPath, ['-e', ''], { + reject: false, + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }); + await expect(child).resolves.toMatchObject({ exitCode: 0 }); + await expect(defaults.sleep(0)).resolves.toBeUndefined(); + expect(defaults.createClient('/tmp/firecracker.socket', 100)).toBeDefined(); + expect(defaults.createNetwork({} as FirecrackerNetworkPlan)).toBeDefined(); + expect(defaults.createWorkspaceImage({ + runId: 'adapter-test', + workDir: '/tmp/awf', + workspacePath: '/workspace', + homePath: '/home/runner', + baseRootfsPath: '/opt/rootfs', + supervisorBinaryPath: '/opt/supervisor', + supervisorSha256: 'a'.repeat(64), + uid: 1000, + gid: 1000, + })).toBeDefined(); + expect(defaults.createVsockClient('/tmp/vsock.socket', 52, 100)).toBeDefined(); + + process.env.SUDO_UID = '2001'; + process.env.SUDO_GID = '2002'; + expect(firecrackerManagerTestHelpers.resolveJailerIdentity()).toEqual({ + uid: 2001, + gid: 2002, + }); + delete process.env.SUDO_UID; + delete process.env.SUDO_GID; + }); + it('constructs unique, contained jail paths', () => { const first = createFirecrackerRunPaths('/tmp/awf', '/opt/firecracker'); const second = createFirecrackerRunPaths('/tmp/awf', '/opt/firecracker'); @@ -354,6 +389,70 @@ describe('FirecrackerManager', () => { expect(order).toEqual(['extract']); }); + it('delegates guest cancellation, stdin, and resize only after readiness', async () => { + const cold = new FirecrackerManager( + config(), + '/tmp/awf', + dependencies(), + 'cold-guest', + networkConfig(), + ); + await expect(cold.cancel()).rejects.toThrow(/supervisor is not ready/); + await expect(cold.writeStdin(Buffer.from('input'))).rejects.toThrow(/supervisor is not ready/); + await expect(cold.endStdin()).rejects.toThrow(/supervisor is not ready/); + await expect(cold.resize(80, 24)).rejects.toThrow(/supervisor is not ready/); + + const guestClient = { + connect: jest.fn().mockResolvedValue(undefined), + execute: jest.fn(), + cancel: jest.fn().mockResolvedValue(undefined), + writeStdin: jest.fn().mockResolvedValue(undefined), + endStdin: jest.fn().mockResolvedValue(undefined), + resize: jest.fn().mockResolvedValue(undefined), + shutdown: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + } as unknown as FirecrackerVsockClient; + const workspace = { + prepare: jest.fn().mockResolvedValue({ + workspaceImagePath: '/tmp/workspace.ext4', + rootfsImagePath: '/tmp/rootfs.ext4', + imageBytes: 1024, + originalManifest: new Map(), + }), + extractAfterStop: jest.fn().mockResolvedValue(undefined), + cleanup: jest.fn().mockResolvedValue(undefined), + } as unknown as FirecrackerWorkspaceImage; + const deps = dependencies({ + createVsockClient: jest.fn().mockReturnValue(guestClient), + createWorkspaceImage: jest.fn().mockReturnValue(workspace), + }); + const manager = new FirecrackerManager( + config(), + '/tmp/awf', + deps, + 'ready-guest', + networkConfig(), + { + workspacePath: '/workspace', + homePath: '/home/runner', + supervisorBinaryPath: '/opt/supervisor', + supervisorSha256: 'a'.repeat(64), + }, + ); + await manager.start(); + await manager.startInstance(); + await manager.cancel('test', 'request'); + await manager.writeStdin(Buffer.from('input'), 'request'); + await manager.endStdin('request'); + await manager.resize(80, 24, 'request'); + + expect(guestClient.cancel).toHaveBeenCalledWith('test', 'request'); + expect(guestClient.writeStdin).toHaveBeenCalledWith(Buffer.from('input'), 'request'); + expect(guestClient.endStdin).toHaveBeenCalledWith('request'); + expect(guestClient.resize).toHaveBeenCalledWith(80, 24, 'request'); + await manager.stop(); + }); + it('quiesces and copies back while preserving jail, images, and network in keep mode', async () => { const child = processMock(); const workspace = { diff --git a/src/firecracker/manager.ts b/src/firecracker/manager.ts index c66513677..21d8caa7b 100644 --- a/src/firecracker/manager.ts +++ b/src/firecracker/manager.ts @@ -107,6 +107,12 @@ const defaultDependencies: FirecrackerManagerDependencies = { resolveIdentity: resolveJailerIdentity, }; +/** @internal Exposed only for focused host-adapter tests. */ +export const firecrackerManagerTestHelpers = { + defaultDependencies, + resolveJailerIdentity, +}; + function parsePositiveIdentity(value: string | undefined): number | undefined { if (!value || !/^[1-9]\d*$/.test(value)) return undefined; return Number(value); diff --git a/src/firecracker/preflight.test.ts b/src/firecracker/preflight.test.ts index 452de75bf..03fed1daa 100644 --- a/src/firecracker/preflight.test.ts +++ b/src/firecracker/preflight.test.ts @@ -1,6 +1,12 @@ import { constants } from 'fs'; +import { createHash } from 'crypto'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { FirecrackerOptions } from '../types/runtime-options'; import { + calculateSha256, + firecrackerPreflightTestHelpers, parseFirecrackerVersion, runFirecrackerPreflight, type FirecrackerPreflightDependencies, @@ -45,8 +51,43 @@ function dependencies( } describe('Firecracker preflight', () => { + let originalPath: string | undefined; + + beforeEach(() => { + originalPath = process.env.PATH; + }); + afterEach(() => { delete process.env.SUDO_UID; + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + }); + + it('runs default version, tool, and digest host probes', async () => { + const defaults = firecrackerPreflightTestHelpers.defaultDependencies; + await expect(defaults.runVersion(process.execPath)).resolves.toContain( + process.version.slice(1), + ); + await expect(defaults.runVersion('/bin/false')).rejects.toThrow( + /--version" exited with code/, + ); + + process.env.PATH = `${path.delimiter}${path.dirname(process.execPath)}`; + await expect(defaults.assertToolAvailable(path.basename(process.execPath))) + .resolves.toBeUndefined(); + await expect(defaults.assertToolAvailable('definitely-not-an-awf-tool')) + .rejects.toThrow(/was not found on PATH/); + + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-preflight-digest-')); + const target = path.join(directory, 'artifact'); + try { + await fs.writeFile(target, 'verified artifact'); + await expect(calculateSha256(target)).resolves.toBe( + createHash('sha256').update('verified artifact').digest('hex'), + ); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } }); it('parses Firecracker and jailer release output', () => { @@ -111,6 +152,78 @@ describe('Firecracker preflight', () => { config({ sha256: { kernel: digest } }), dependencies({ sha256: jest.fn().mockResolvedValue('b'.repeat(64)) }), )).rejects.toThrow(/SHA-256 mismatch/); + + await expect(runFirecrackerPreflight( + config({ sha256: { kernel: 'bad' } }), + dependencies(), + )).rejects.toThrow(/must contain exactly 64 hexadecimal/); + }); + + it('rejects missing artifacts, unsupported hosts, and unavailable tools', async () => { + await expect(runFirecrackerPreflight( + config({ supervisorPath: undefined }), + dependencies(), + )).rejects.toThrow(/requires guest kernel, rootfs, and supervisor/); + await expect(runFirecrackerPreflight( + config(), + dependencies({ platform: 'darwin' }), + )).rejects.toThrow(/requires Linux with KVM/); + await expect(runFirecrackerPreflight( + config(), + dependencies({ arch: 'ia32' }), + )).rejects.toThrow(/supports only x86_64 and aarch64/); + await expect(runFirecrackerPreflight( + config(), + dependencies({ + assertToolAvailable: jest.fn().mockRejectedValue('missing'), + }), + )).rejects.toThrow(/requires host tool "ip": missing/); + }); + + it('rejects untrusted artifact files and inaccessible paths', async () => { + await expect(runFirecrackerPreflight( + config({ firecrackerBinary: 'relative/firecracker' }), + dependencies(), + )).rejects.toThrow(/path must be absolute/); + await expect(runFirecrackerPreflight( + config(), + dependencies({ + lstat: jest.fn(async (filePath: string) => ( + filePath === '/opt/firecracker' + ? { + isFile: () => false, + isSymbolicLink: () => true, + mode: 0o120777, + uid: 0, + } + : { + isFile: () => false, + isSymbolicLink: () => false, + mode: 0o040755, + uid: 0, + } + )), + }), + )).rejects.toThrow(/regular file and not a symbolic link/); + await expect(runFirecrackerPreflight( + config(), + dependencies({ + lstat: jest.fn().mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100755, + uid: 4000, + }), + }), + )).rejects.toThrow(/must be owned by root or uid/); + await expect(runFirecrackerPreflight( + config(), + dependencies({ + access: jest.fn(async (filePath: string) => { + if (filePath !== '/dev/kvm') throw new Error('EACCES'); + }), + }), + )).rejects.toThrow(/does not have the required host access/); }); it('uses SUDO_UID as trusted owner when running under sudo', async () => { diff --git a/src/firecracker/preflight.ts b/src/firecracker/preflight.ts index a4b8bbbf0..6aa5f7696 100644 --- a/src/firecracker/preflight.ts +++ b/src/firecracker/preflight.ts @@ -58,6 +58,9 @@ const defaultDependencies: FirecrackerPreflightDependencies = { }, }; +/** @internal Exposed only for focused host-probe tests. */ +export const firecrackerPreflightTestHelpers = { defaultDependencies }; + export interface FirecrackerPreflightResult { version: string; firecrackerBinary: string; diff --git a/src/firecracker/runtime-validation.test.ts b/src/firecracker/runtime-validation.test.ts new file mode 100644 index 000000000..9204e4967 --- /dev/null +++ b/src/firecracker/runtime-validation.test.ts @@ -0,0 +1,106 @@ +import type { WrapperConfig } from '../types'; +import { + assertFirecrackerPreSecurityCompatibility, + assertFirecrackerRuntimeCompatibility, + assertFirecrackerSelection, + requireFirecrackerConfig, +} from './runtime-validation'; + +const digest = 'a'.repeat(64); + +function config(overrides: Partial = {}): WrapperConfig { + return { + containerRuntime: 'firecracker', + networkIsolation: true, + legacySecurity: false, + enableApiProxy: true, + enableDind: false, + enableHostAccess: false, + tty: false, + firecracker: { + previewEnabled: true, + firecrackerBinary: '/opt/firecracker', + jailerBinary: '/opt/jailer', + kernelPath: '/opt/kernel', + rootfsPath: '/opt/rootfs', + supervisorPath: '/opt/supervisor', + vcpuCount: 2, + memoryMib: 512, + apiTimeoutMs: 5000, + sha256: { + firecracker: digest, + jailer: digest, + kernel: digest, + rootfs: digest, + supervisor: digest, + }, + }, + ...overrides, + } as WrapperConfig; +} + +describe('Firecracker runtime validation', () => { + it('accepts only a complete explicitly selected preview', () => { + const valid = config(); + expect(() => assertFirecrackerSelection(valid)).not.toThrow(); + expect(() => assertFirecrackerRuntimeCompatibility(valid)).not.toThrow(); + expect(requireFirecrackerConfig(valid)).toBe(valid.firecracker); + + expect(() => assertFirecrackerSelection(config({ + containerRuntime: 'gvisor', + }))).toThrow(/require --container-runtime firecracker/); + expect(() => requireFirecrackerConfig(config({ + containerRuntime: 'gvisor', + }))).toThrow(/resolved without Firecracker runtime configuration/); + }); + + it.each([ + [{ firecracker: { ...config().firecracker!, previewEnabled: false } }, /explicit --firecracker-preview/], + [{ networkIsolation: false }, /strict --network-isolation/], + [{ legacySecurity: true }, /strict --network-isolation/], + [{ enableApiProxy: false }, /API proxy credential isolation/], + [{ + firecracker: { + ...config().firecracker!, + supervisorPath: undefined, + }, + }, /explicit kernel, rootfs, and guest supervisor/], + [{ + firecracker: { + ...config().firecracker!, + sha256: { ...config().firecracker!.sha256, supervisor: undefined }, + }, + }, /requires SHA-256 digests/], + ] as const)('rejects incomplete runtime configuration %#', (overrides, error) => { + expect(() => assertFirecrackerRuntimeCompatibility( + config(overrides as Partial), + )).toThrow(error); + }); + + it.each([ + [{ networkIsolation: false }, /cannot disable --network-isolation/], + [{ enableDind: true }, /Docker-in-Docker/], + [{ dockerHostPathPrefix: '/host' }, /split filesystems/], + [{ runnerTopology: 'arc-dind' }, /split filesystems/], + [{ enableHostAccess: true }, /host access/], + [{ allowHostPorts: ['8080'] }, /host access/], + [{ allowHostServicePorts: ['5432'] }, /host access/], + [{ volumeMounts: ['/tmp:/tmp'] }, /additional host volume mounts/], + [{ topologyAttach: ['gateway'] }, /MCP gateway path/], + [{ difcProxyHost: 'proxy:443' }, /MCP gateway path/], + [{ enclaves: { enabled: true } }, /MCP gateway path/], + [{ dnsOverHttps: 'https://dns.example/dns-query' }, /DNS-over-HTTPS/], + [{ tty: true }, /does not support --tty/], + [{ awfDockerHost: 'tcp://localhost:2375' }, /local Unix-socket Docker daemon/], + ] as const)('rejects unsupported preview policy %#', (overrides, error) => { + expect(() => assertFirecrackerPreSecurityCompatibility( + config(overrides as Partial), + )).toThrow(error); + }); + + it('accepts a local Unix Docker socket', () => { + expect(() => assertFirecrackerPreSecurityCompatibility(config({ + awfDockerHost: 'unix:///var/run/docker.sock', + }))).not.toThrow(); + }); +}); From 8592125131933573853e9ddeea8c8e79627de42c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:53:29 +0000 Subject: [PATCH 3/4] fix: harden Firecracker runtime preview Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- src/compose-generator.ts | 2 +- src/firecracker-runtime-backend.test.ts | 51 +++++++++++++++++++++- src/firecracker-runtime-backend.ts | 47 +++++++++++++++----- src/firecracker/infrastructure.ts | 14 +++--- src/firecracker/manager.test.ts | 10 +++++ src/firecracker/manager.ts | 20 ++++++--- src/firecracker/network.ts | 27 +++++++----- src/firecracker/preflight.test.ts | 29 +++++++++++-- src/firecracker/preflight.ts | 55 +++++++++++++++++++++--- src/firecracker/workspace-image.ts | 30 +++++++++---- src/services/agent-service-build.test.ts | 11 +++++ 11 files changed, 242 insertions(+), 54 deletions(-) diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 22e21165f..6a479c833 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -156,7 +156,7 @@ export function generateDockerCompose( // In network-isolation mode the internal network blocks host→container traffic, // so we also attach api-proxy to the external bridge (`awf-ext`) — same as // Squid — so published ports are reachable from outside Docker. - if (!includeAgent && services['api-proxy']) { + if (!includeAgent && config.containerRuntime !== 'firecracker' && services['api-proxy']) { const proxyService = services['api-proxy']; if (!proxyService.ports) { proxyService.ports = []; diff --git a/src/firecracker-runtime-backend.test.ts b/src/firecracker-runtime-backend.test.ts index b748ee247..831c2a8a3 100644 --- a/src/firecracker-runtime-backend.test.ts +++ b/src/firecracker-runtime-backend.test.ts @@ -68,6 +68,24 @@ function infrastructure(): FirecrackerInfrastructureSnapshot { }; } +const preflightResult = { + version: '1.16.1', + firecrackerBinary: '/opt/firecracker', + jailerBinary: '/opt/jailer', + kernelPath: '/opt/kernel', + rootfsPath: '/opt/rootfs', + supervisorPath: '/opt/supervisor', + tools: { + ip: '/usr/bin/ip', + nft: '/usr/sbin/nft', + sysctl: '/usr/sbin/sysctl', + mke2fs: '/usr/sbin/mke2fs', + debugfs: '/usr/sbin/debugfs', + e2fsck: '/usr/sbin/e2fsck', + rsync: '/usr/bin/rsync', + }, +}; + function harness(overrides: Partial = {}) { const order: string[] = []; const stdin = new PassThrough(); @@ -99,7 +117,7 @@ function harness(overrides: Partial = {}) }); const deps: FirecrackerRuntimeBackendDependencies = { startInfrastructure: jest.fn(async () => { order.push('compose'); }), - preflight: jest.fn(async () => { order.push('preflight'); }), + preflight: jest.fn(async () => { order.push('preflight'); return preflightResult; }), resolveInfrastructure: jest.fn(async () => infra), createManager: jest.fn(() => manager), workspacePath: () => '/workspace-host', @@ -180,6 +198,37 @@ describe('Firecracker runtime backend', () => { ); }); + it('rejects timeouts beyond the guest supervisor limit before infrastructure startup', async () => { + const { deps } = harness(); + const backend = new FirecrackerRuntimeBackend(config({ agentTimeout: 1441 }), deps); + + await expect(backend.start('/tmp/awf', ['github.com'])) + .rejects.toThrow(/up to 1440 minutes/); + expect(deps.startInfrastructure).not.toHaveBeenCalled(); + }); + + it('serializes stdin chunks before sending EOF', async () => { + const { manager, deps, stdin } = harness(); + let releaseFirstWrite!: () => void; + manager.writeStdin.mockImplementationOnce(() => new Promise((resolve) => { + releaseFirstWrite = resolve; + })); + const backend = new FirecrackerRuntimeBackend(config(), deps); + await backend.start('/tmp/awf', ['github.com']); + const execution = backend.exec('/tmp/awf', ['github.com']); + stdin.write(Buffer.alloc(70_000, 1)); + stdin.end('second'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(manager.endStdin).not.toHaveBeenCalled(); + releaseFirstWrite(); + await execution; + expect(manager.writeStdin.mock.invocationCallOrder[0]) + .toBeLessThan(manager.writeStdin.mock.invocationCallOrder[1]); + expect(manager.writeStdin.mock.invocationCallOrder[1]) + .toBeLessThan(manager.endStdin.mock.invocationCallOrder[0]); + }); + it('stops the partial VM when readiness probing fails', async () => { const { manager, deps } = harness(); manager.execute.mockReset().mockResolvedValue({ diff --git a/src/firecracker-runtime-backend.ts b/src/firecracker-runtime-backend.ts index 5fcffb60a..508d54a2b 100644 --- a/src/firecracker-runtime-backend.ts +++ b/src/firecracker-runtime-backend.ts @@ -10,6 +10,7 @@ import { resolveFirecrackerInfrastructure, type FirecrackerInfrastructureSnapshot, } from './firecracker/infrastructure'; +import type { FirecrackerPreflightResult } from './firecracker/preflight'; import { FirecrackerManager } from './firecracker/manager'; import { runFirecrackerPreflight } from './firecracker/preflight'; import type { @@ -34,6 +35,7 @@ const FIRECRACKER_GUEST_WORKSPACE = '/workspace'; const FIRECRACKER_GUEST_HOME = `${FIRECRACKER_GUEST_WORKSPACE}/.awf-home`; const FIRECRACKER_PROBE_TIMEOUT_MS = 15_000; const FIRECRACKER_CANCEL_GRACE_MS = 3_000; +const FIRECRACKER_MAX_TIMEOUT_MS = 86_400_000; interface FirecrackerBackendLogger { debug(message: string, ...args: unknown[]): void; @@ -56,14 +58,15 @@ interface FirecrackerManagerAdapter { export interface FirecrackerRuntimeBackendDependencies { startInfrastructure: WorkflowDependencies['startContainers']; - preflight(config: FirecrackerOptions): Promise; - resolveInfrastructure(enableApiProxy: boolean): Promise; + preflight(config: FirecrackerOptions): Promise; + resolveInfrastructure(enableApiProxy: boolean, ipPath?: string): Promise; createManager( config: FirecrackerOptions, workDir: string, infrastructure: FirecrackerInfrastructureSnapshot, workspacePath: string, homePath: string, + identity: { uid: number; gid: number }, ): FirecrackerManagerAdapter; workspacePath(): string; homePath(): string; @@ -96,6 +99,7 @@ function defaultDependencies( homePath, supervisorBinaryPath: config.supervisorPath!, supervisorSha256: config.sha256!.supervisor!, + identity, }, ), workspacePath: () => process.env.GITHUB_WORKSPACE || process.cwd(), @@ -125,6 +129,8 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { | undefined; private stopped = false; private stopping: Promise | undefined; + private identity: { uid: number; gid: number } | undefined; + private preflightResult: FirecrackerPreflightResult | undefined; constructor( private readonly config: WrapperConfig, @@ -133,8 +139,18 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { async preflight(): Promise { const firecracker = requireFirecrackerConfig(this.config); + if ( + this.config.agentTimeout !== undefined && + this.config.agentTimeout * 60_000 > FIRECRACKER_MAX_TIMEOUT_MS + ) { + throw new Error( + `Firecracker preview supports --agent-timeout values up to ${ + FIRECRACKER_MAX_TIMEOUT_MS / 60_000 + } minutes`, + ); + } assertFirecrackerRuntimeCompatibility(this.config, firecracker); - await this.dependencies.preflight(firecracker); + this.preflightResult = await this.dependencies.preflight(firecracker); } readonly start: WorkflowDependencies['startContainers'] = async ( @@ -158,13 +174,16 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { const firecracker = requireFirecrackerConfig(this.config); const infrastructure = await this.dependencies.resolveInfrastructure( Boolean(this.config.enableApiProxy), + this.preflightResult?.tools.ip, ); + this.identity = this.dependencies.identity(); this.manager = this.dependencies.createManager( firecracker, workDir, infrastructure, this.dependencies.workspacePath(), this.dependencies.homePath(), + this.identity, ); try { @@ -222,7 +241,7 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { argv: ['/bin/bash', '-lc', this.config.agentCommand], env: environment, cwd: FIRECRACKER_GUEST_WORKSPACE, - ...this.dependencies.identity(), + ...this.identity, tty: false, ...(timeoutMs === undefined ? {} : { timeoutMs }), stdout: this.dependencies.stdout, @@ -230,17 +249,24 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { }); this.activeExecution = { requestId, promise: execution }; - const onData = (chunk: Buffer | string): void => { - const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - void manager.writeStdin(data, requestId).catch((error) => { + let forwarding = Promise.resolve(); + let stdinEnded = false; + const forward = (operation: () => Promise): void => { + forwarding = forwarding.then(operation).catch((error) => { this.dependencies.logger.warn( `Firecracker guest stdin forwarding failed: ${formatError(error)}`, ); - void manager.cancel('stdin forwarding failure', requestId).catch(() => undefined); + return manager.cancel('stdin forwarding failure', requestId).catch(() => undefined); }); }; + const onData = (chunk: Buffer | string): void => { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + forward(() => manager.writeStdin(data, requestId)); + }; const onEnd = (): void => { - void manager.endStdin(requestId).catch(() => undefined); + if (stdinEnded) return; + stdinEnded = true; + forward(() => manager.endStdin(requestId)); }; this.dependencies.stdin.on('data', onData); this.dependencies.stdin.once('end', onEnd); @@ -256,6 +282,7 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { } finally { this.dependencies.stdin.off('data', onData); this.dependencies.stdin.off('end', onEnd); + await forwarding; this.activeExecution = undefined; } }; @@ -330,7 +357,7 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { argv: ['/bin/sh', '-c', `set -eu; ${squidProbe}${apiProxyProbe}`], env: environment, cwd: FIRECRACKER_GUEST_WORKSPACE, - ...this.dependencies.identity(), + ...this.identity, timeoutMs: FIRECRACKER_PROBE_TIMEOUT_MS, }); if (result.exitCode !== 0) { diff --git a/src/firecracker/infrastructure.ts b/src/firecracker/infrastructure.ts index 74a7e8986..1a97f4cc8 100644 --- a/src/firecracker/infrastructure.ts +++ b/src/firecracker/infrastructure.ts @@ -53,7 +53,7 @@ export interface FirecrackerInfrastructureSnapshot { export interface FirecrackerInfrastructureDependencies { inspectNetwork(): Promise; - inspectLink(bridgeName: string): Promise; + inspectLink(bridgeName: string, ipPath?: string): Promise; } const defaultDependencies: FirecrackerInfrastructureDependencies = { @@ -71,8 +71,8 @@ const defaultDependencies: FirecrackerInfrastructureDependencies = { } return JSON.parse(result.stdout) as unknown; }, - inspectLink: async (bridgeName) => { - const result = await execa('ip', ['-json', '-details', 'link', 'show', 'dev', bridgeName], { + inspectLink: async (bridgeName, ipPath = 'ip') => { + const result = await execa(ipPath, ['-json', '-details', 'link', 'show', 'dev', bridgeName], { reject: false, timeout: 5_000, }); @@ -94,12 +94,13 @@ const defaultDependencies: FirecrackerInfrastructureDependencies = { export async function resolveFirecrackerInfrastructure( enableApiProxy: boolean, dependencies: FirecrackerInfrastructureDependencies = defaultDependencies, + ipPath?: string, ): Promise { - const resolved = await inspectInfrastructure(enableApiProxy, dependencies); + const resolved = await inspectInfrastructure(enableApiProxy, dependencies, ipPath); return { ...resolved, revalidate: async () => { - const live = await inspectInfrastructure(enableApiProxy, dependencies); + const live = await inspectInfrastructure(enableApiProxy, dependencies, ipPath); if ( live.networkId !== resolved.networkId || live.bridgeName !== resolved.bridgeName || @@ -120,6 +121,7 @@ export async function resolveFirecrackerInfrastructure( async function inspectInfrastructure( enableApiProxy: boolean, dependencies: FirecrackerInfrastructureDependencies, + ipPath?: string, ): Promise> { const raw = await dependencies.inspectNetwork(); if (!Array.isArray(raw) || raw.length !== 1) { @@ -160,7 +162,7 @@ async function inspectInfrastructure( network.Options?.['com.docker.network.bridge.name']; const bridgeName = configuredBridge || `br-${network.Id.slice(0, 12)}`; assertInterfaceName(bridgeName); - const rawLinks = await dependencies.inspectLink(bridgeName); + const rawLinks = await dependencies.inspectLink(bridgeName, ipPath); if (!Array.isArray(rawLinks) || rawLinks.length !== 1) { throw new Error(`Expected exactly one host bridge named "${bridgeName}"`); } diff --git a/src/firecracker/manager.test.ts b/src/firecracker/manager.test.ts index 146ccf76d..dfd7c02d2 100644 --- a/src/firecracker/manager.test.ts +++ b/src/firecracker/manager.test.ts @@ -23,6 +23,16 @@ function config(overrides: Partial = {}): FirecrackerOptions jailerBinary: '/opt/jailer', kernelPath: '/opt/vmlinux', rootfsPath: '/opt/rootfs.ext4', + supervisorPath: '/opt/awf-supervisor', + tools: { + ip: '/usr/bin/ip', + nft: '/usr/sbin/nft', + sysctl: '/usr/sbin/sysctl', + mke2fs: '/usr/sbin/mke2fs', + debugfs: '/usr/sbin/debugfs', + e2fsck: '/usr/sbin/e2fsck', + rsync: '/usr/bin/rsync', + }, vcpuCount: 2, memoryMib: 512, apiTimeoutMs: 1, diff --git a/src/firecracker/manager.ts b/src/firecracker/manager.ts index 21d8caa7b..bd02b7f79 100644 --- a/src/firecracker/manager.ts +++ b/src/firecracker/manager.ts @@ -5,6 +5,7 @@ import execa, { type ExecaChildProcess } from 'execa'; import type { FirecrackerOptions } from '../types/runtime-options'; import { FirecrackerApiClient } from './api-client'; import { + FirecrackerLinuxNetworkCommands, FirecrackerNetworkManager, assertSafeFirecrackerRunId, createFirecrackerNetworkPlan, @@ -13,6 +14,7 @@ import { type FirecrackerNetworkPlan, } from './network'; import { runFirecrackerPreflight } from './preflight'; +import type { FirecrackerHostToolPaths } from './preflight'; import { FirecrackerVsockClient, type FirecrackerGuestExecutionRequest, @@ -63,8 +65,8 @@ export interface FirecrackerManagerDependencies { rm(directory: string, options: { recursive: true; force: true }): Promise; sleep(milliseconds: number): Promise; createClient(socketPath: string, timeoutMs: number): FirecrackerApiClient; - createNetwork(plan: FirecrackerNetworkPlan): FirecrackerNetworkLifecycle; - createWorkspaceImage(config: FirecrackerWorkspaceImageConfig): FirecrackerWorkspaceImage; + createNetwork(plan: FirecrackerNetworkPlan, tools: FirecrackerHostToolPaths): FirecrackerNetworkLifecycle; + createWorkspaceImage(config: FirecrackerWorkspaceImageConfig, tools: FirecrackerHostToolPaths): FirecrackerWorkspaceImage; createVsockClient(socketPath: string, guestPort: number, timeoutMs: number): FirecrackerVsockClient; resolveIdentity(): { uid: number; gid: number }; } @@ -82,6 +84,7 @@ export interface FirecrackerManagerGuestConfig { readonly supervisorSha256: string; readonly maxWorkspaceImageBytes?: number; readonly vsockPort?: number; + readonly identity?: { uid: number; gid: number }; } const defaultDependencies: FirecrackerManagerDependencies = { @@ -95,8 +98,11 @@ const defaultDependencies: FirecrackerManagerDependencies = { rm: fs.rm, sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), createClient: (socketPath, timeoutMs) => new FirecrackerApiClient({ socketPath, timeoutMs }), - createNetwork: (plan) => new FirecrackerNetworkManager(plan), - createWorkspaceImage: (config) => new FirecrackerWorkspaceImage(config), + createNetwork: (plan, tools) => new FirecrackerNetworkManager( + plan, + new FirecrackerLinuxNetworkCommands(undefined, tools), + ), + createWorkspaceImage: (config, tools) => new FirecrackerWorkspaceImage(config, undefined, tools), createVsockClient: (socketPath, guestPort, timeoutMs) => new FirecrackerVsockClient({ socketPath, guestPort, @@ -196,14 +202,14 @@ export class FirecrackerManager { let startupError: unknown; try { const artifacts = await this.dependencies.preflight(this.config); - const identity = this.dependencies.resolveIdentity(); + const identity = this.guestConfig?.identity ?? this.dependencies.resolveIdentity(); const networkPlan = createFirecrackerNetworkPlan(this.paths.runId, { ...this.networkConfig, jailerUid: identity.uid, jailerGid: identity.gid, }); this.networkPlan = networkPlan; - this.network = this.dependencies.createNetwork(networkPlan); + this.network = this.dependencies.createNetwork(networkPlan, artifacts.tools); await this.network.setup(); let rootfsSource = artifacts.rootfsPath; let workspaceSource: string | undefined; @@ -221,7 +227,7 @@ export class FirecrackerManager { : { maxImageBytes: this.guestConfig.maxWorkspaceImageBytes }), uid: identity.uid, gid: identity.gid, - }); + }, artifacts.tools); const preparation = await this.workspace.prepare(); rootfsSource = preparation.rootfsImagePath; workspaceSource = preparation.workspaceImagePath; diff --git a/src/firecracker/network.ts b/src/firecracker/network.ts index 5e2d61975..9c55ceeef 100644 --- a/src/firecracker/network.ts +++ b/src/firecracker/network.ts @@ -1,5 +1,6 @@ import { createHash } from 'crypto'; import execa from 'execa'; +import type { FirecrackerHostToolPaths } from './preflight'; import { AGENT_IP, API_PROXY_IP, @@ -81,20 +82,24 @@ const defaultCommandExecutor: FirecrackerNetworkCommandExecutor = async ( args, options, ) => { - if (command !== 'ip') { - throw new Error(`Unsupported Firecracker network command: ${command}`); - } - await execa('ip', [...args], options); + await execa(command, [...args], options); }; /** * Dependency-injected argv-only Linux networking operations. */ export class FirecrackerLinuxNetworkCommands { - constructor(private readonly execute: FirecrackerNetworkCommandExecutor = defaultCommandExecutor) {} + constructor( + private readonly execute: FirecrackerNetworkCommandExecutor = defaultCommandExecutor, + private readonly tools: Pick = { + ip: 'ip', + nft: 'nft', + sysctl: 'sysctl', + }, + ) {} ip(args: readonly string[], reject = true): Promise { - return this.execute('ip', args, { reject }); + return this.execute(this.tools.ip, args, { reject }); } ipInNamespace( @@ -102,7 +107,7 @@ export class FirecrackerLinuxNetworkCommands { args: readonly string[], reject = true, ): Promise { - return this.execute('ip', ['netns', 'exec', namespaceName, 'ip', ...args], { reject }); + return this.execute(this.tools.ip, ['netns', 'exec', namespaceName, this.tools.ip, ...args], { reject }); } sysctlInNamespace( @@ -111,8 +116,8 @@ export class FirecrackerLinuxNetworkCommands { reject = true, ): Promise { return this.execute( - 'ip', - ['netns', 'exec', namespaceName, 'sysctl', '-q', '-w', setting], + this.tools.ip, + ['netns', 'exec', namespaceName, this.tools.sysctl, '-q', '-w', setting], { reject }, ); } @@ -124,8 +129,8 @@ export class FirecrackerLinuxNetworkCommands { reject = true, ): Promise { return this.execute( - 'ip', - ['netns', 'exec', namespaceName, 'nft', ...args], + this.tools.ip, + ['netns', 'exec', namespaceName, this.tools.nft, ...args], { reject, ...(input === undefined ? {} : { input }) }, ); } diff --git a/src/firecracker/preflight.test.ts b/src/firecracker/preflight.test.ts index 03fed1daa..df891e360 100644 --- a/src/firecracker/preflight.test.ts +++ b/src/firecracker/preflight.test.ts @@ -45,7 +45,7 @@ function dependencies( }), runVersion: jest.fn().mockResolvedValue('Firecracker v1.16.1'), sha256: jest.fn().mockResolvedValue(digest), - assertToolAvailable: jest.fn().mockResolvedValue(undefined), + assertToolAvailable: jest.fn(async (tool: string) => `/usr/bin/${tool}`), ...overrides, }; } @@ -74,7 +74,7 @@ describe('Firecracker preflight', () => { process.env.PATH = `${path.delimiter}${path.dirname(process.execPath)}`; await expect(defaults.assertToolAvailable(path.basename(process.execPath))) - .resolves.toBeUndefined(); + .resolves.toBe(process.execPath); await expect(defaults.assertToolAvailable('definitely-not-an-awf-tool')) .rejects.toThrow(/was not found on PATH/); @@ -114,7 +114,16 @@ describe('Firecracker preflight', () => { constants.R_OK | constants.W_OK, ); expect(deps.sha256).toHaveBeenCalledTimes(5); - expect(deps.assertToolAvailable).toHaveBeenCalledTimes(6); + expect(deps.assertToolAvailable).toHaveBeenCalledTimes(7); + expect(result.tools).toEqual({ + ip: '/usr/bin/ip', + nft: '/usr/bin/nft', + sysctl: '/usr/bin/sysctl', + mke2fs: '/usr/bin/mke2fs', + debugfs: '/usr/bin/debugfs', + e2fsck: '/usr/bin/e2fsck', + rsync: '/usr/bin/rsync', + }); }); it('rejects inaccessible KVM without checking artifacts', async () => { @@ -283,4 +292,18 @@ describe('Firecracker preflight', () => { dependencies({ lstat: symlinkParent }), )).rejects.toThrow(/parent directory must not be a symbolic link/); }); + + it('rejects user-controlled PATH tools', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-preflight-tool-')); + const tool = path.join(directory, 'ip'); + await fs.writeFile(tool, '#!/bin/sh\n'); + await fs.chmod(tool, 0o755); + process.env.PATH = directory; + try { + await expect(firecrackerPreflightTestHelpers.defaultDependencies.assertToolAvailable('ip')) + .rejects.toThrow(/trusted host tool "ip"/); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/src/firecracker/preflight.ts b/src/firecracker/preflight.ts index 6aa5f7696..5f8efd7f4 100644 --- a/src/firecracker/preflight.ts +++ b/src/firecracker/preflight.ts @@ -20,9 +20,22 @@ export interface FirecrackerPreflightDependencies { }>; runVersion(binaryPath: string): Promise; sha256(filePath: string): Promise; - assertToolAvailable(tool: string): Promise; + assertToolAvailable(tool: string): Promise; } +export type FirecrackerHostToolPaths = Readonly<{ + ip: string; + nft: string; + sysctl: string; + mke2fs: string; + debugfs: string; + e2fsck: string; + rsync: string; +}>; +const FIRECRACKER_HOST_TOOLS: (keyof FirecrackerHostToolPaths)[] = [ + 'ip', 'nft', 'sysctl', 'mke2fs', 'debugfs', 'e2fsck', 'rsync', +]; + const defaultDependencies: FirecrackerPreflightDependencies = { platform: process.platform, arch: process.arch, @@ -48,13 +61,14 @@ const defaultDependencies: FirecrackerPreflightDependencies = { for (const directory of searchPath.split(path.delimiter)) { if (!directory) continue; try { - await fs.access(path.join(directory, tool), constants.X_OK); - return; + const candidate = path.join(directory, tool); + await assertTrustedHostTool(tool, candidate); + return candidate; } catch { // Continue searching the bounded host PATH. } } - throw new Error(`required host tool "${tool}" was not found on PATH`); + throw new Error(`required trusted host tool "${tool}" was not found on PATH`); }, }; @@ -68,6 +82,33 @@ export interface FirecrackerPreflightResult { kernelPath: string; rootfsPath: string; supervisorPath: string; + tools: FirecrackerHostToolPaths; +} + +async function assertTrustedHostTool(label: string, filePath: string): Promise { + if (!path.isAbsolute(filePath)) { + throw new Error(`host tool "${label}" path must be absolute: ${filePath}`); + } + const { root } = path.parse(filePath); + const segments = filePath.slice(root.length).split('/').filter(Boolean); + let ancestor = root; + for (const segment of segments.slice(0, -1)) { + ancestor = path.join(ancestor, segment); + const stat = await fs.lstat(ancestor); + if (stat.isSymbolicLink() || (stat.mode & 0o022) !== 0 || stat.uid !== 0) { + throw new Error(`host tool "${label}" has an untrusted parent directory: ${ancestor}`); + } + } + const stat = await fs.lstat(filePath); + if ( + stat.isSymbolicLink() || + !stat.isFile() || + (stat.mode & 0o022) !== 0 || + stat.uid !== 0 + ) { + throw new Error(`host tool "${label}" must be a root-owned non-writable regular file: ${filePath}`); + } + await fs.access(filePath, constants.X_OK); } export function parseFirecrackerVersion(output: string): string { @@ -217,9 +258,10 @@ export async function runFirecrackerPreflight( dependencies, ); - for (const tool of ['ip', 'nft', 'mke2fs', 'debugfs', 'e2fsck', 'rsync']) { + const tools = {} as Record; + for (const tool of FIRECRACKER_HOST_TOOLS) { try { - await dependencies.assertToolAvailable(tool); + tools[tool] = await dependencies.assertToolAvailable(tool); } catch (error) { throw new Error( `Firecracker requires host tool "${tool}": ` + @@ -307,5 +349,6 @@ export async function runFirecrackerPreflight( kernelPath: config.kernelPath, rootfsPath: config.rootfsPath, supervisorPath: config.supervisorPath, + tools, }; } diff --git a/src/firecracker/workspace-image.ts b/src/firecracker/workspace-image.ts index 119d8e2aa..abb67a167 100644 --- a/src/firecracker/workspace-image.ts +++ b/src/firecracker/workspace-image.ts @@ -2,6 +2,7 @@ import { createHash } from 'crypto'; import { createReadStream, promises as fs, type Stats } from 'fs'; import * as path from 'path'; import execa from 'execa'; +import type { FirecrackerHostToolPaths } from './preflight'; import { CREDENTIAL_ENTRIES, HOME_TOOL_SUBDIRS, @@ -43,7 +44,7 @@ export type FirecrackerWorkspaceManifest = ReadonlyMap< >; export interface FirecrackerWorkspaceImageDependencies { - runTool(command: 'mke2fs' | 'debugfs' | 'e2fsck' | 'rsync', args: readonly string[]): Promise; + runTool(command: string, args: readonly string[]): Promise; } const defaultDependencies: FirecrackerWorkspaceImageDependencies = { @@ -54,7 +55,10 @@ const defaultDependencies: FirecrackerWorkspaceImageDependencies = { timeout: 120_000, }); if (result.exitCode === 0) return; - if (command === 'e2fsck' && result.exitCode === FIRECRACKER_E2FSCK_REPAIR_EXIT_CODE) return; + if ( + (command === 'e2fsck' || command.endsWith('/e2fsck')) && + result.exitCode === FIRECRACKER_E2FSCK_REPAIR_EXIT_CODE + ) return; throw new Error( `${command} exited with code ${result.exitCode}: ` + `${result.stderr.trim() || result.stdout.trim()}`, @@ -86,6 +90,7 @@ export class FirecrackerWorkspaceImage { constructor( private readonly config: FirecrackerWorkspaceImageConfig, private readonly dependencies: FirecrackerWorkspaceImageDependencies = defaultDependencies, + private readonly tools?: Pick, ) { assertSafeRunId(config.runId); this.runDirectory = path.join(config.workDir, 'firecracker-images', config.runId); @@ -99,6 +104,13 @@ export class FirecrackerWorkspaceImage { ); } + private runTool( + command: 'mke2fs' | 'debugfs' | 'e2fsck' | 'rsync', + args: readonly string[], + ): Promise { + return this.dependencies.runTool(this.tools?.[command] ?? command, args); + } + async prepare(): Promise { if (this.prepared) throw new Error('Firecracker workspace image is already prepared'); await fs.mkdir(path.join(this.stagingDirectory, 'workspace'), { @@ -150,7 +162,7 @@ export class FirecrackerWorkspaceImage { } finally { await workspaceImage.close(); } - await this.dependencies.runTool('mke2fs', [ + await this.runTool('mke2fs', [ '-t', 'ext4', '-F', '-q', @@ -184,8 +196,8 @@ export class FirecrackerWorkspaceImage { await fs.rm(extractionDirectory, { recursive: true, force: true }); await fs.mkdir(extractionDirectory, { recursive: true, mode: 0o700 }); assertDebugfsOperand(extractionDirectory, 'extraction directory'); - await this.dependencies.runTool('e2fsck', ['-f', '-y', changedImagePath]); - await this.dependencies.runTool('debugfs', [ + await this.runTool('e2fsck', ['-f', '-y', changedImagePath]); + await this.runTool('debugfs', [ '-R', `rdump / ${extractionDirectory}`, changedImagePath, ]); @@ -278,17 +290,17 @@ export class FirecrackerWorkspaceImage { await fs.copyFile(this.config.supervisorBinaryPath, localSupervisor); await fs.chmod(localSupervisor, 0o500); assertDebugfsOperand(localSupervisor, 'supervisor staging path'); - await this.dependencies.runTool('debugfs', [ + await this.runTool('debugfs', [ '-w', '-R', `write ${localSupervisor} /sbin/awf-supervisor`, this.rootfsImagePath, ]); - await this.dependencies.runTool('debugfs', [ + await this.runTool('debugfs', [ '-w', '-R', 'sif /sbin/awf-supervisor mode 0100755', this.rootfsImagePath, ]); - await this.dependencies.runTool('e2fsck', ['-f', '-y', this.rootfsImagePath]); + await this.runTool('e2fsck', ['-f', '-y', this.rootfsImagePath]); } private async preserveRecoveryImage(changedImagePath: string): Promise { @@ -321,7 +333,7 @@ export class FirecrackerWorkspaceImage { await fs.rm(mergeDirectory, { recursive: true, force: true }); await fs.rm(backupDirectory, { recursive: true, force: true }); await fs.mkdir(mergeDirectory, { recursive: true, mode: 0o700 }); - await this.dependencies.runTool('rsync', [ + await this.runTool('rsync', [ '-a', '--delete', '--safe-links', diff --git a/src/services/agent-service-build.test.ts b/src/services/agent-service-build.test.ts index b732050c5..209f1ec9e 100644 --- a/src/services/agent-service-build.test.ts +++ b/src/services/agent-service-build.test.ts @@ -635,5 +635,16 @@ describe('agent service', () => { expect(agent.extra_hosts?.['squid-proxy']).toBeUndefined(); expect(agent.extra_hosts?.['api-proxy']).toBeUndefined(); }); + + it('keeps Firecracker API proxy ports and networks internal', () => { + const result = generateDockerCompose( + { ...mockConfig, containerRuntime: 'firecracker', enableApiProxy: true }, + { ...mockNetworkConfig, proxyIp: '172.30.0.30' }, + ); + const proxy = result.services['api-proxy'] as any; + + expect(proxy.ports).toBeUndefined(); + expect(proxy.networks?.['awf-ext']).toBeUndefined(); + }); }); }); From 92e9ddb5e342d28705c8c7b2cbc6663e99089e27 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 9 Aug 2026 16:04:45 -0700 Subject: [PATCH 4/4] fix: align Firecracker hardening adapters Adapt resolved host tools and identity to the hardened layer-4 contracts, and update focused tests to exercise the rewritten interfaces. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/firecracker-runtime-backend.test.ts | 25 ++++++++++++++++ src/firecracker-runtime-backend.ts | 16 +++++++---- src/firecracker/infrastructure.test.ts | 2 +- src/firecracker/manager.test.ts | 38 ++++++++++++++----------- src/firecracker/preflight.test.ts | 6 ++-- 5 files changed, 62 insertions(+), 25 deletions(-) diff --git a/src/firecracker-runtime-backend.test.ts b/src/firecracker-runtime-backend.test.ts index 831c2a8a3..365920a64 100644 --- a/src/firecracker-runtime-backend.test.ts +++ b/src/firecracker-runtime-backend.test.ts @@ -157,6 +157,7 @@ describe('Firecracker runtime backend', () => { infrastructure(), '/workspace', '/home/runner', + { uid: 1000, gid: 1000 }, )).toBeDefined(); expect(createFirecrackerRuntimeBackend(config(), startInfrastructure)) .toBeInstanceOf(FirecrackerRuntimeBackend); @@ -210,6 +211,23 @@ describe('Firecracker runtime backend', () => { it('serializes stdin chunks before sending EOF', async () => { const { manager, deps, stdin } = harness(); let releaseFirstWrite!: () => void; + let resolveExecution!: (value: { + requestId: string; + exitCode: number; + signal: null; + timedOut: boolean; + }) => void; + manager.execute + .mockReset() + .mockResolvedValueOnce({ + requestId: 'probe', + exitCode: 0, + signal: null, + timedOut: false, + }) + .mockReturnValueOnce(new Promise((resolve) => { + resolveExecution = resolve; + })); manager.writeStdin.mockImplementationOnce(() => new Promise((resolve) => { releaseFirstWrite = resolve; })); @@ -222,6 +240,13 @@ describe('Firecracker runtime backend', () => { expect(manager.endStdin).not.toHaveBeenCalled(); releaseFirstWrite(); + await new Promise((resolve) => setImmediate(resolve)); + resolveExecution({ + requestId: 'agent', + exitCode: 0, + signal: null, + timedOut: false, + }); await execution; expect(manager.writeStdin.mock.invocationCallOrder[0]) .toBeLessThan(manager.writeStdin.mock.invocationCallOrder[1]); diff --git a/src/firecracker-runtime-backend.ts b/src/firecracker-runtime-backend.ts index 508d54a2b..9f0fb562d 100644 --- a/src/firecracker-runtime-backend.ts +++ b/src/firecracker-runtime-backend.ts @@ -83,8 +83,9 @@ function defaultDependencies( return { startInfrastructure, preflight: runFirecrackerPreflight, - resolveInfrastructure: resolveFirecrackerInfrastructure, - createManager: (config, workDir, infrastructure, workspacePath, homePath) => + resolveInfrastructure: (enableApiProxy, ipPath) => + resolveFirecrackerInfrastructure(enableApiProxy, undefined, ipPath), + createManager: (config, workDir, infrastructure, workspacePath, homePath, identity) => new FirecrackerManager( config, workDir, @@ -223,7 +224,8 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { ) => { const manager = this.manager; const environment = this.environment; - if (!manager || !environment) { + const identity = this.identity; + if (!manager || !environment || !identity) { throw new Error('Firecracker microVM is not ready'); } if (this.config.tty) { @@ -241,7 +243,7 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { argv: ['/bin/bash', '-lc', this.config.agentCommand], env: environment, cwd: FIRECRACKER_GUEST_WORKSPACE, - ...this.identity, + ...identity, tty: false, ...(timeoutMs === undefined ? {} : { timeoutMs }), stdout: this.dependencies.stdout, @@ -345,6 +347,10 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { private async probeGuestConnectivity(): Promise { const manager = this.manager!; const environment = this.environment!; + const identity = this.identity; + if (!identity) { + throw new Error('Firecracker guest identity is not ready'); + } const squidProbe = `curl --silent --show-error --max-time 5 --output /dev/null ` + `http://${SQUID_IP}:3128/`; @@ -357,7 +363,7 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { argv: ['/bin/sh', '-c', `set -eu; ${squidProbe}${apiProxyProbe}`], env: environment, cwd: FIRECRACKER_GUEST_WORKSPACE, - ...this.identity, + ...identity, timeoutMs: FIRECRACKER_PROBE_TIMEOUT_MS, }); if (result.exitCode !== 0) { diff --git a/src/firecracker/infrastructure.test.ts b/src/firecracker/infrastructure.test.ts index 6d2ebfb6b..7f2552a04 100644 --- a/src/firecracker/infrastructure.test.ts +++ b/src/firecracker/infrastructure.test.ts @@ -117,7 +117,7 @@ describe('Firecracker infrastructure discovery', () => { })); await resolved.revalidate(); expect(deps.inspectNetwork).toHaveBeenCalledTimes(2); - expect(deps.inspectLink).toHaveBeenCalledWith(`br-${'a'.repeat(12)}`); + expect(deps.inspectLink).toHaveBeenCalledWith(`br-${'a'.repeat(12)}`, undefined); }); it('rejects ambiguous, non-internal, or address-shifted topology', async () => { diff --git a/src/firecracker/manager.test.ts b/src/firecracker/manager.test.ts index dfd7c02d2..7aa31b998 100644 --- a/src/firecracker/manager.test.ts +++ b/src/firecracker/manager.test.ts @@ -15,6 +15,17 @@ import type { } from './network'; import type { FirecrackerVsockClient } from './vsock-client'; import type { FirecrackerWorkspaceImage } from './workspace-image'; +import type { FirecrackerHostToolPaths } from './preflight'; + +const hostTools: FirecrackerHostToolPaths = { + ip: '/usr/bin/ip', + nft: '/usr/sbin/nft', + sysctl: '/usr/sbin/sysctl', + mke2fs: '/usr/sbin/mke2fs', + debugfs: '/usr/sbin/debugfs', + e2fsck: '/usr/sbin/e2fsck', + rsync: '/usr/bin/rsync', +}; function config(overrides: Partial = {}): FirecrackerOptions { return { @@ -24,15 +35,6 @@ function config(overrides: Partial = {}): FirecrackerOptions kernelPath: '/opt/vmlinux', rootfsPath: '/opt/rootfs.ext4', supervisorPath: '/opt/awf-supervisor', - tools: { - ip: '/usr/bin/ip', - nft: '/usr/sbin/nft', - sysctl: '/usr/sbin/sysctl', - mke2fs: '/usr/sbin/mke2fs', - debugfs: '/usr/sbin/debugfs', - e2fsck: '/usr/sbin/e2fsck', - rsync: '/usr/bin/rsync', - }, vcpuCount: 2, memoryMib: 512, apiTimeoutMs: 1, @@ -90,6 +92,7 @@ function dependencies( jailerBinary: '/opt/jailer', kernelPath: '/opt/vmlinux', rootfsPath: '/opt/rootfs.ext4', + tools: hostTools, }), launch: jest.fn().mockReturnValue(processMock()), mkdir: jest.fn().mockResolvedValue(undefined), @@ -119,7 +122,7 @@ describe('FirecrackerManager', () => { await expect(child).resolves.toMatchObject({ exitCode: 0 }); await expect(defaults.sleep(0)).resolves.toBeUndefined(); expect(defaults.createClient('/tmp/firecracker.socket', 100)).toBeDefined(); - expect(defaults.createNetwork({} as FirecrackerNetworkPlan)).toBeDefined(); + expect(defaults.createNetwork({} as FirecrackerNetworkPlan, hostTools)).toBeDefined(); expect(defaults.createWorkspaceImage({ runId: 'adapter-test', workDir: '/tmp/awf', @@ -130,7 +133,7 @@ describe('FirecrackerManager', () => { supervisorSha256: 'a'.repeat(64), uid: 1000, gid: 1000, - })).toBeDefined(); + }, hostTools)).toBeDefined(); expect(defaults.createVsockClient('/tmp/vsock.socket', 52, 100)).toBeDefined(); process.env.SUDO_UID = '2001'; @@ -207,11 +210,14 @@ describe('FirecrackerManager', () => { .mock.calls[0][0] as { guest_mac: string }; expect(configuredNetwork.guest_mac.split(':')).toHaveLength(6); expect(configuredNetwork.guest_mac.startsWith('02:')).toBe(true); - expect(deps.createNetwork).toHaveBeenCalledWith(expect.objectContaining({ - infrastructureBridge: 'awfbr0', - jailerUid: 1000, - jailerGid: 1000, - })); + expect(deps.createNetwork).toHaveBeenCalledWith( + expect.objectContaining({ + infrastructureBridge: 'awfbr0', + jailerUid: 1000, + jailerGid: 1000, + }), + hostTools, + ); const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0] .value as FirecrackerNetworkLifecycle; expect(lifecycle.setup).toHaveBeenCalledTimes(1); diff --git a/src/firecracker/preflight.test.ts b/src/firecracker/preflight.test.ts index df891e360..046443fb5 100644 --- a/src/firecracker/preflight.test.ts +++ b/src/firecracker/preflight.test.ts @@ -72,9 +72,9 @@ describe('Firecracker preflight', () => { /--version" exited with code/, ); - process.env.PATH = `${path.delimiter}${path.dirname(process.execPath)}`; - await expect(defaults.assertToolAvailable(path.basename(process.execPath))) - .resolves.toBe(process.execPath); + process.env.PATH = `${path.delimiter}/usr/bin`; + await expect(defaults.assertToolAvailable('false')) + .resolves.toBe('/usr/bin/false'); await expect(defaults.assertToolAvailable('definitely-not-an-awf-tool')) .rejects.toThrow(/was not found on PATH/);