From 804d36e171e661ee603c2cef0fe31c47fd5c730e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 13:04:20 +0000 Subject: [PATCH 1/2] Add microVM isolation mode for project labs via Kata Containers Adds a `microvm` LabIsolationMode alongside the existing container boundary: docker-plan.ts registers a `--runtime ` OCI runtime and audit labels, service.ts threads the choice through create/start/restart (persisted in lab metadata like networkProfile), and runtime.ts fails closed with MicrovmRuntimeUnavailableError rather than silently downgrading isolation when the requested Kata runtime class is not registered with the Docker daemon. Documents setup requirements and limitations in docs/lab-runtime-hardening.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FYzvg68ntwkoSEQJ1NkC6a --- docs/lab-runtime-hardening.md | 27 +++++ src/server/labs/docker-plan.ts | 24 +++++ src/server/labs/hardening.ts | 22 +++- src/server/labs/runtime.ts | 44 +++++++- src/server/labs/service.ts | 35 ++++++- src/server/labs/types.ts | 11 ++ tests/security-chat/lab-runtime.test.ts | 127 ++++++++++++++++++++++++ 7 files changed, 284 insertions(+), 6 deletions(-) diff --git a/docs/lab-runtime-hardening.md b/docs/lab-runtime-hardening.md index d8e292d86..cb79556e9 100644 --- a/docs/lab-runtime-hardening.md +++ b/docs/lab-runtime-hardening.md @@ -21,6 +21,32 @@ The backend command builder applies these Docker settings by default: The default Linux user is `root` because the upstream `kalilinux/kali-rolling` image does not ship a `kali` account. The container still runs with dropped Linux capabilities, `no-new-privileges`, no Docker socket, and no host path mounts by default. Custom lab images can provide and select an unprivileged user when stronger in-container user separation is needed. +## MicroVM Isolation (Kata Containers) + +The default `container` isolation mode above shares the host kernel with every lab: namespaces, cgroups, and dropped capabilities narrow the attack surface, but a container escape still lands on the host kernel. Lab boundary options include a `microvm` isolation mode that runs the same lab container under a [Kata Containers](https://katacontainers.io/) OCI runtime class, so each lab gets its own guest kernel inside a hardware-virtualized microVM (Firecracker, QEMU, or Cloud Hypervisor). This is additive to, not a replacement for, the capability/network/resource hardening described above. + +### Enabling it + +- Per-lab: pass `isolation: "microvm"` (and optionally `microvmRuntimeClass`) to the lab create/start/restart API. The choice is persisted in lab metadata, so subsequent starts reuse it. +- Host-wide default: set `PROJECT_LAB_ISOLATION_MODE=microvm`, and optionally `PROJECT_LAB_MICROVM_RUNTIME_CLASS` to override the default runtime class. +- Runtime class names map directly to `docker run --runtime ` and must already be registered in the Docker daemon's `runtimes` config (`/etc/docker/daemon.json`) by a Kata Containers install: + - `kata-fc` (default) — Firecracker VMM. Smallest device model and strongest isolation, but only virtio net/block/vsock devices are available to the guest. + - `kata-qemu` — QEMU VMM. Broader device/hardware compatibility if the Kali workspace image needs devices Firecracker does not emulate. + - `kata-clh` — Cloud Hypervisor VMM. A middle ground between the two. + +### Fail-closed behavior + +Because isolation strength is a security property, the runtime never silently downgrades from `microvm` to plain containers. Before starting a lab with `isolation: "microvm"`, it runs `docker info --format '{{json .Runtimes}}'` and refuses to start (throwing `MicrovmRuntimeUnavailableError`) if the requested runtime class is not registered with the Docker daemon. In `dry-run` mode the availability check is included in the planned command list but is never executed or enforced. + +### Host setup + +MicroVM isolation requires a Docker host with: + +- Nested virtualization / bare-metal KVM access (`/dev/kvm` present, `vmx`/`svm` CPU flag set). +- [Kata Containers](https://github.com/kata-containers/kata-containers/blob/main/docs/install/README.md) installed and registered with the Docker daemon under the runtime class names above. + +This is a host/infrastructure prerequisite outside of ExploitHunter.app's process; most local developer machines, CI runners, and constrained sandboxes do not expose `/dev/kvm` and cannot run `microvm` isolation at all. Use `container` isolation (the default) in those environments, and reserve `microvm` for hosts that are provisioned for it. + ## Network Profiles Human profiles: @@ -90,3 +116,4 @@ Network profile changes are bound to durable approvals. The `agentLabCommandTool - DNS resolution for approved targets uses the container's configured DNS resolver. The iptables rules match against resolved IP addresses at connection time, not domain names. This means the hostname-based allowlisting is resolved at the time each connection is made. - The iptables enforcement runs inside the container and can theoretically be modified by a root user inside the container. The `--cap-drop ALL` before adding `NET_ADMIN`/`NET_RAW` means only these specific networking capabilities are available. - For stronger enforcement, pair with a dedicated Docker network that has its own egress firewall, or use a sidecar proxy (Envoy, SOCKS5) in the container network namespace. +- Namespace/cgroup isolation shares the host kernel with the lab workload. For labs where a kernel-level container escape is an unacceptable risk (for example, running untrusted exploit code or malware samples), use `microvm` isolation (see above) so the lab runs in its own guest kernel instead of the host's. diff --git a/src/server/labs/docker-plan.ts b/src/server/labs/docker-plan.ts index 9b7bcd881..b51fe4c1a 100644 --- a/src/server/labs/docker-plan.ts +++ b/src/server/labs/docker-plan.ts @@ -11,7 +11,10 @@ import { LAB_HARDENING_LABELS, LAB_HOME_PATH, LAB_WORKSPACE_PATH, + SAFE_MICROVM_RUNTIME_CLASS, dockerHardeningArgs, + resolveLabIsolationMode, + resolveMicrovmRuntimeClass, } from "./hardening"; import { dockerNetworkArgs, getLabNetworkProfile, buildEgressEnforcementArgs, buildEgressIptablesScript } from "./network-profiles"; import type { DockerCommandSpec, LabContainerIdentity, LabContainerOptions, LabResourceLimits } from "./types"; @@ -137,9 +140,14 @@ export const buildRunLabCommand = (options: LabContainerOptions): DockerCommandS const user = options.user ?? DEFAULT_KALI_LAB_USER; const networkProfile = getLabNetworkProfile(options.boundary, options.networkProfile); const limits = mergeResourceLimits(options); + const isolation = resolveLabIsolationMode(options.isolation); + const microvmRuntimeClass = isolation === "microvm" ? resolveMicrovmRuntimeClass(options.microvmRuntimeClass) : null; assertSafeValue("image ref", image, SAFE_IMAGE_REF); assertSafeValue("user", user, SAFE_USER); + if (microvmRuntimeClass) { + assertSafeValue("microVM runtime class", microvmRuntimeClass, SAFE_MICROVM_RUNTIME_CLASS); + } return { command: "docker", @@ -154,10 +162,13 @@ export const buildRunLabCommand = (options: LabContainerOptions): DockerCommandS user, "--workdir", LAB_WORKSPACE_PATH, + ...(microvmRuntimeClass ? ["--runtime", microvmRuntimeClass] : []), ...labelArgs({ ...identity.labels, "exploit-hunter.network-profile": networkProfile.id, "exploit-hunter.image-ref": image, + "exploit-hunter.isolation": isolation, + ...(microvmRuntimeClass ? { "exploit-hunter.microvm-runtime-class": microvmRuntimeClass } : {}), }), ...dockerHardeningArgs(options.boundary, limits), ...buildEgressEnforcementArgs({ @@ -202,6 +213,19 @@ export const buildStartLabCommands = (options: LabContainerOptions): DockerComma buildRunLabCommand(options), ]; +export const buildMicrovmRuntimeCheckCommand = (options: LabContainerOptions): DockerCommandSpec | null => { + if (resolveLabIsolationMode(options.isolation) !== "microvm") { + return null; + } + const runtimeClass = resolveMicrovmRuntimeClass(options.microvmRuntimeClass); + assertSafeValue("microVM runtime class", runtimeClass, SAFE_MICROVM_RUNTIME_CLASS); + return { + command: "docker", + args: ["info", "--format", "{{json .Runtimes}}"], + reason: `Verify the ${runtimeClass} microVM runtime is registered with the Docker daemon before starting an isolated lab.`, + }; +}; + export const buildStopLabCommand = (options: LabContainerOptions): DockerCommandSpec => ({ command: "docker", args: ["stop", labContainerIdentity(options).containerName], diff --git a/src/server/labs/hardening.ts b/src/server/labs/hardening.ts index 43b29b2e2..70c8f8801 100644 --- a/src/server/labs/hardening.ts +++ b/src/server/labs/hardening.ts @@ -1,4 +1,4 @@ -import type { LabBoundary, LabResourceLimits } from "./types"; +import type { LabBoundary, LabIsolationMode, LabResourceLimits } from "./types"; export const PREVIOUS_KALI_LAB_IMAGE = "exploit-hunter/kali-workspace:latest"; export const DEFAULT_KALI_LAB_IMAGE = "exploit-hunter/kali-workspace:iproute2"; @@ -68,3 +68,23 @@ export const dockerHardeningArgs = (boundary: LabBoundary, limits: LabResourceLi "--label", `exploit-hunter.lab.boundary=${boundary}`, ]; + +// Kata Containers registers hardware-virtualized OCI runtime classes with the Docker +// daemon (`/etc/docker/daemon.json` "runtimes"); each name below selects a different VMM. +// kata-fc (Firecracker) is the strictest microVM boundary but supports only virtio +// net/block/vsock devices; kata-qemu and kata-clh (Cloud Hypervisor) trade some of that +// isolation minimalism for broader device/hardware compatibility with the Kali image. +export const MICROVM_RUNTIME_CLASSES = ["kata-fc", "kata-qemu", "kata-clh"] as const; +export const DEFAULT_LAB_ISOLATION_MODE: LabIsolationMode = "container"; +export const DEFAULT_MICROVM_RUNTIME_CLASS: (typeof MICROVM_RUNTIME_CLASSES)[number] = "kata-fc"; +export const SAFE_MICROVM_RUNTIME_CLASS = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/; + +export const resolveLabIsolationMode = (isolation?: LabIsolationMode): LabIsolationMode => { + if (isolation === "microvm" || isolation === "container") { + return isolation; + } + return process.env.PROJECT_LAB_ISOLATION_MODE === "microvm" ? "microvm" : DEFAULT_LAB_ISOLATION_MODE; +}; + +export const resolveMicrovmRuntimeClass = (runtimeClass?: string): string => + runtimeClass?.trim() || process.env.PROJECT_LAB_MICROVM_RUNTIME_CLASS?.trim() || DEFAULT_MICROVM_RUNTIME_CLASS; diff --git a/src/server/labs/runtime.ts b/src/server/labs/runtime.ts index c98124528..783875e49 100644 --- a/src/server/labs/runtime.ts +++ b/src/server/labs/runtime.ts @@ -8,13 +8,14 @@ import { buildBuildLabImageCommand, buildDestroyLabContainerCommand, buildInspectLabImageCommand, + buildMicrovmRuntimeCheckCommand, buildProvisionLabCommands, buildRunLabCommand, isManagedKaliLabImage, buildStopLabCommand, labContainerIdentity, } from "./docker-plan"; -import { DEFAULT_KALI_LAB_IMAGE } from "./hardening"; +import { DEFAULT_KALI_LAB_IMAGE, resolveMicrovmRuntimeClass } from "./hardening"; import { buildEgressIptablesScript, buildRuntimeFirewallScript } from "./network-profiles"; import type { DockerCommandSpec, @@ -31,6 +32,17 @@ import type { const execFileAsync = promisify(execFile); +export class MicrovmRuntimeUnavailableError extends Error { + constructor(runtimeClass: string, cause?: string) { + super( + `MicroVM isolation was requested but the "${runtimeClass}" Docker runtime is not registered on this host` + + `${cause ? ` (${cause})` : ""}. Install and register Kata Containers (see docs/lab-runtime-hardening.md) ` + + `or set PROJECT_LAB_ISOLATION_MODE=container.`, + ); + this.name = "MicrovmRuntimeUnavailableError"; + } +} + export interface DockerCommandRunner { isAvailable(): Promise; run( @@ -98,6 +110,10 @@ export class ProjectLabRuntime { async start(options: LabContainerOptions): Promise { const mode = await this.resolveMode(); + const microvmCheckCommand = buildMicrovmRuntimeCheckCommand(options); + if (microvmCheckCommand && mode === "docker") { + await this.assertMicrovmRuntimeAvailable(options, microvmCheckCommand); + } const imageCommands = await this.ensureManagedImage(options, mode); const provisionCommands = buildProvisionLabCommands(options); const commands = [...imageCommands, ...provisionCommands]; @@ -105,6 +121,7 @@ export class ProjectLabRuntime { if (mode === "dry-run") { const planned = [ + ...(microvmCheckCommand ? [microvmCheckCommand] : []), ...commands, { command: "docker" as const, @@ -392,6 +409,31 @@ export class ProjectLabRuntime { } } + private async assertMicrovmRuntimeAvailable(options: LabContainerOptions, checkCommand: DockerCommandSpec): Promise { + const runtimeClass = resolveMicrovmRuntimeClass(options.microvmRuntimeClass); + let stdout: string; + try { + stdout = (await this.runner.run(checkCommand, { timeoutMs: 5_000 })).stdout; + } catch (error) { + throw new MicrovmRuntimeUnavailableError(runtimeClass, error instanceof Error ? error.message : String(error)); + } + + let runtimes: unknown; + try { + runtimes = JSON.parse(stdout || "{}"); + } catch { + runtimes = {}; + } + + const isRegistered = + runtimes !== null && typeof runtimes === "object" && !Array.isArray(runtimes) && + Object.prototype.hasOwnProperty.call(runtimes, runtimeClass); + + if (!isRegistered) { + throw new MicrovmRuntimeUnavailableError(runtimeClass); + } + } + private async applyEgressIptables(options: LabContainerOptions, identity: ReturnType): Promise { const script = buildEgressIptablesScript({ profileId: options.networkProfile ?? "offline", diff --git a/src/server/labs/service.ts b/src/server/labs/service.ts index f89bc980e..1e54a9198 100644 --- a/src/server/labs/service.ts +++ b/src/server/labs/service.ts @@ -6,13 +6,19 @@ import type { LabCredentialMount, HumanLabNetworkProfileId, LabFirewallConfigInput, + LabIsolationMode, LabRuntimeResult, NormalizedLabCreateInput, ProjectLabRepository, ProjectLabStatusView, } from "./types"; import { ProjectLabRuntime } from "./runtime"; -import { DEFAULT_KALI_LAB_IMAGE, PREVIOUS_KALI_LAB_IMAGE, UPSTREAM_KALI_LAB_IMAGE } from "./hardening"; +import { + DEFAULT_KALI_LAB_IMAGE, + PREVIOUS_KALI_LAB_IMAGE, + UPSTREAM_KALI_LAB_IMAGE, + resolveLabIsolationMode, +} from "./hardening"; import { buildUfwCommandPlan, normalizeLabFirewallConfig } from "./network-profiles"; const LEGACY_SIMULATED_IMAGE_REF = "exploit-hunter-lab:simulated"; @@ -100,11 +106,15 @@ export class ProjectLabService { } const networkProfile = readHumanNetworkProfile(input.networkProfile ?? lab.metadata.networkProfile); + const isolation = readLabIsolationMode(input.isolation ?? lab.metadata.isolation); + const microvmRuntimeClass = readMicrovmRuntimeClass(input.microvmRuntimeClass ?? lab.metadata.microvmRuntimeClass); const runtimeResult = await this.runtime.start({ projectId, boundary: "human", image: lab.image_ref, networkProfile, + isolation, + microvmRuntimeClass, credentialMounts: readCredentialMounts(input.credentialMounts ?? lab.metadata.credentialMounts), }); const runtimeId = lab.runtime_id ?? runtimeResult.container.containerName; @@ -119,7 +129,7 @@ export class ProjectLabService { started_at: lab.started_at ?? timestamp(), stopped_at: null, destroyed_at: null, - metadata: mergeMetadata(lab.metadata, labPreferenceMetadata({ ...input, networkProfile })), + metadata: mergeMetadata(lab.metadata, labPreferenceMetadata({ ...input, networkProfile, isolation, microvmRuntimeClass })), }); return this.statusFromLab(projectId, running); @@ -167,11 +177,15 @@ export class ProjectLabService { networkProfile: "offline", }); const networkProfile = readHumanNetworkProfile(input.networkProfile ?? lab.metadata.networkProfile); + const isolation = readLabIsolationMode(input.isolation ?? lab.metadata.isolation); + const microvmRuntimeClass = readMicrovmRuntimeClass(input.microvmRuntimeClass ?? lab.metadata.microvmRuntimeClass); const startResult = await this.runtime.start({ projectId, boundary: "human", image: lab.image_ref, networkProfile, + isolation, + microvmRuntimeClass, credentialMounts: readCredentialMounts(input.credentialMounts ?? lab.metadata.credentialMounts), }); const runtimeResult = mergeRuntimeResults(destroyResult, startResult); @@ -186,7 +200,7 @@ export class ProjectLabService { started_at: timestamp(), stopped_at: null, destroyed_at: null, - metadata: mergeMetadata(lab.metadata, labPreferenceMetadata({ ...input, networkProfile })), + metadata: mergeMetadata(lab.metadata, labPreferenceMetadata({ ...input, networkProfile, isolation, microvmRuntimeClass })), }); return this.statusFromLab(projectId, running); @@ -316,9 +330,14 @@ function labDefaults(input: LabCreateInput): NormalizedLabCreateInput { } function labPreferenceMetadata(input: LabCreateInput): JsonObject { + const microvmRuntimeClass = readMicrovmRuntimeClass(input.microvmRuntimeClass); return mergeMetadata( mergeMetadata(sanitizeJsonObject(input.metadata), credentialMountMetadata(input.credentialMounts)), - { networkProfile: readHumanNetworkProfile(input.networkProfile) }, + { + networkProfile: readHumanNetworkProfile(input.networkProfile), + isolation: readLabIsolationMode(input.isolation), + ...(microvmRuntimeClass ? { microvmRuntimeClass } : {}), + }, ); } @@ -328,6 +347,14 @@ function readHumanNetworkProfile(value: unknown): HumanLabNetworkProfileId { : DEFAULT_HUMAN_NETWORK_PROFILE; } +function readLabIsolationMode(value: unknown): LabIsolationMode { + return resolveLabIsolationMode(value === "microvm" || value === "container" ? value : undefined); +} + +function readMicrovmRuntimeClass(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + function readFirewallConfig(value: unknown): LabFirewallConfigInput | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as LabFirewallConfigInput diff --git a/src/server/labs/types.ts b/src/server/labs/types.ts index 2e8fab13f..899132e1e 100644 --- a/src/server/labs/types.ts +++ b/src/server/labs/types.ts @@ -33,6 +33,8 @@ export interface LabCreateInput { networkProfile?: HumanLabNetworkProfileId; firewall?: LabFirewallConfigInput; credentialMounts?: LabCredentialMount[]; + isolation?: LabIsolationMode; + microvmRuntimeClass?: string; metadata?: JsonObject; } @@ -62,6 +64,13 @@ export interface ProjectLabRepository { export type LabBoundary = "human" | "agent"; +/** + * "container" is the default Linux-namespace/cgroup boundary (see hardening.ts). + * "microvm" runs the lab under a hardware-virtualized Kata Containers runtime class + * for kernel-level isolation between the lab workload and the Docker host. + */ +export type LabIsolationMode = "container" | "microvm"; + export type HumanLabNetworkProfileId = "offline" | "package-egress" | "full"; export type AgentLabNetworkProfileId = "offline" | "approved-targets" | "package-egress" | "full"; @@ -107,6 +116,8 @@ export interface LabContainerOptions extends LabProjectRef { networkName?: string; approvedTargets?: string[]; credentialMounts?: LabCredentialMount[]; + isolation?: LabIsolationMode; + microvmRuntimeClass?: string; } export interface DockerCommandSpec { diff --git a/tests/security-chat/lab-runtime.test.ts b/tests/security-chat/lab-runtime.test.ts index aa15178ea..9caaf966c 100644 --- a/tests/security-chat/lab-runtime.test.ts +++ b/tests/security-chat/lab-runtime.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it } from "vitest"; import { AGENT_LAB_NETWORK_PROFILES, DEFAULT_KALI_LAB_IMAGE, + DEFAULT_MICROVM_RUNTIME_CLASS, HUMAN_LAB_PACKAGE_CAPABILITIES, HUMAN_LAB_NETWORK_PROFILES, + MicrovmRuntimeUnavailableError, ProjectLabRuntime, + buildMicrovmRuntimeCheckCommand, buildRuntimeFirewallScript, buildRunLabCommand, buildUfwCommandPlan, @@ -408,3 +411,127 @@ describe("project lab Docker runtime primitives", () => { ]); }); }); + +describe("microVM isolation", () => { + it("stays on the plain container boundary by default, with no --runtime flag or check command", () => { + const command = buildRunLabCommand({ ...humanOptions, networkProfile: "offline" }); + + expect(command.args).not.toEqual(expect.arrayContaining(["--runtime"])); + expect(command.args).toEqual(expect.arrayContaining(["exploit-hunter.isolation=container"])); + expect(buildMicrovmRuntimeCheckCommand({ ...humanOptions })).toBeNull(); + }); + + it("adds a --runtime flag and audit labels when microVM isolation is requested", () => { + const command = buildRunLabCommand({ + ...humanOptions, + networkProfile: "offline", + isolation: "microvm", + }); + + expect(command.args[command.args.indexOf("--runtime") + 1]).toBe(DEFAULT_MICROVM_RUNTIME_CLASS); + expect(command.args).toEqual( + expect.arrayContaining([ + "exploit-hunter.isolation=microvm", + `exploit-hunter.microvm-runtime-class=${DEFAULT_MICROVM_RUNTIME_CLASS}`, + ]), + ); + }); + + it("honors an explicit microVM runtime class override", () => { + const command = buildRunLabCommand({ + ...humanOptions, + networkProfile: "offline", + isolation: "microvm", + microvmRuntimeClass: "kata-qemu", + }); + + expect(command.args[command.args.indexOf("--runtime") + 1]).toBe("kata-qemu"); + }); + + it("rejects unsafe microVM runtime class names", () => { + expect(() => + buildRunLabCommand({ + ...humanOptions, + networkProfile: "offline", + isolation: "microvm", + microvmRuntimeClass: "kata-fc; rm -rf /", + }), + ).toThrow(/Unsafe microVM runtime class/); + }); + + it("plans a Docker daemon runtime-availability check when isolation is microvm in dry-run mode", async () => { + const runner: DockerCommandRunner = { + async isAvailable() { + return false; + }, + async run() { + throw new Error("dry-run must not execute docker"); + }, + }; + const runtime = new ProjectLabRuntime({ mode: "auto", runner }); + + const result = await runtime.start({ + projectId: "microvm-dry-run-project", + boundary: "agent", + networkProfile: "offline", + isolation: "microvm", + }); + + expect(result.mode).toBe("dry-run"); + expect(result.commands[0]?.args).toEqual(["info", "--format", "{{json .Runtimes}}"]); + }); + + it("fails closed when the requested microVM runtime is not registered with the Docker daemon", async () => { + const runner: DockerCommandRunner = { + async isAvailable() { + return true; + }, + async run(command) { + if (command.args[0] === "info") { + return { stdout: JSON.stringify({ runc: {} }), stderr: "", exitCode: 0 }; + } + throw new Error("must not provision a lab when the microVM runtime check fails"); + }, + }; + const runtime = new ProjectLabRuntime({ mode: "docker", runner }); + + await expect( + runtime.start({ + projectId: "microvm-missing-runtime-project", + boundary: "human", + networkProfile: "offline", + isolation: "microvm", + }), + ).rejects.toBeInstanceOf(MicrovmRuntimeUnavailableError); + }); + + it("starts the lab once the requested microVM runtime is registered with the Docker daemon", async () => { + const executed: DockerCommandSpec[] = []; + const runner: DockerCommandRunner = { + async isAvailable() { + return true; + }, + async run(command) { + executed.push(command); + if (command.args[0] === "info") { + return { stdout: JSON.stringify({ "kata-fc": {}, runc: {} }), stderr: "", exitCode: 0 }; + } + if (command.args.includes('{{ index .Config.Labels "exploit-hunter.image-ref" }}')) { + return { stdout: `${DEFAULT_KALI_LAB_IMAGE}\n`, stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + }; + const runtime = new ProjectLabRuntime({ mode: "docker", runner }); + + const result = await runtime.start({ + projectId: "microvm-available-project", + boundary: "human", + networkProfile: "offline", + isolation: "microvm", + }); + + expect(result.mode).toBe("docker"); + expect(executed[0]?.args).toEqual(["info", "--format", "{{json .Runtimes}}"]); + }); +}); From 8f6713e79ce22992722aeb5b6a2e4be76f2180e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 13:15:29 +0000 Subject: [PATCH 2/2] Create the local sqlite directory before opening libSQL storage LibSQLStore fails with ConnectionFailed(...code: 14) when the parent directory of a local sqlite file (e.g. the default .data/mastra.sqlite, which is gitignored) doesn't exist yet, breaking Playwright's webServer boot on a fresh checkout/CI runner. Create the directory first for file: URIs before constructing the store. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FYzvg68ntwkoSEQJ1NkC6a --- src/mastra/config/storage.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/mastra/config/storage.ts b/src/mastra/config/storage.ts index b2b70b6d8..ee33f97d9 100644 --- a/src/mastra/config/storage.ts +++ b/src/mastra/config/storage.ts @@ -1,3 +1,6 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + import { FilesystemStore, InMemoryStore, MastraCompositeStore } from "@mastra/core/storage"; import nextEnv from "@next/env"; import { ClickhouseStoreVNext } from "@mastra/clickhouse"; @@ -33,9 +36,21 @@ function localPath(uri: string, scheme: "duckdb" | "lance" | "lancedb") { return uri.slice(`${scheme}://`.length) || "."; } +// libSQL's local sqlite backend fails to open a file whose parent directory does not +// exist yet (e.g. a fresh checkout before `.data/` has ever been created); `libsql://` +// remote URLs and `:memory:` are not local files and need no directory. +function ensureLocalSqliteDirectory(uri: string) { + if (!uri.startsWith("file:")) return; + const path = uri.slice("file:".length); + if (!path || path === ":memory:") return; + const dir = dirname(path); + if (dir && dir !== ".") mkdirSync(dir, { recursive: true }); +} + async function createMastraStorageAdapter(config: MastraStorageConfig, id: string): Promise { switch (config.adapter) { case "libsql": + ensureLocalSqliteDirectory(config.uri); return new LibSQLStore({ id, url: config.uri, connectionTimeoutMs: Number.parseInt(process.env.SQLITE_BUSY_TIMEOUT_MS ?? "5000", 10) }); case "postgres": return new PostgresStore({ id, connectionString: config.uri }); case "redis": return new RedisStore({ id, connectionString: config.uri });