From 8e8569a911302002ef131e194254fc4b42086503 Mon Sep 17 00:00:00 2001 From: laosb Date: Sat, 8 Aug 2026 17:09:18 +0800 Subject: [PATCH 1/2] feat(docker): custom Docker runtime (runsc, kata, etc.). --- .boite/.gitignore | 23 + .../boite.json | 42 ++ .github/workflows/test.yml | 115 ++++ README.md | 17 + Sources/AgentIsolation/ContainerRuntime.swift | 23 +- Sources/AgentIsolation/ProjectSettings.swift | 19 +- .../DockerAPIClient.swift | 16 + .../DockerModels.swift | 27 + .../DockerRuntime.swift | 107 ++- .../DockerRuntimeSelection.swift | 264 ++++++++ Sources/agentc/SessionRunner.swift | 14 +- Sources/agentc/SharedOptions.swift | 22 + .../DockerRuntimeSelectionTests.swift | 609 ++++++++++++++++++ .../ProjectSettingsTests.swift | 16 + docs/docker-runtimes.md | 50 ++ docs/project-settings.md | 6 +- 16 files changed, 1360 insertions(+), 10 deletions(-) create mode 100644 .boite/.gitignore create mode 100644 .boite/F02D376A-B248-47BF-8EB3-A2E246B8B910/boite.json create mode 100644 Sources/AgentIsolationDockerRuntime/DockerRuntimeSelection.swift create mode 100644 Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeSelectionTests.swift create mode 100644 docs/docker-runtimes.md diff --git a/.boite/.gitignore b/.boite/.gitignore new file mode 100644 index 0000000..816bc5f --- /dev/null +++ b/.boite/.gitignore @@ -0,0 +1,23 @@ +# Written by Boite the first time it stored a boite in this project. +# It won't be rewritten — edit freely. +# +# Tracked on purpose: boite.json (panels, layout, working directory) and +# panels/*.sh (per-panel init scripts). Those are why a boite lives in +# the repo — clone it, open it, get the same terminal. + +# Regenerated from Boite's types on every save, and replaced whenever the +# schema version moves. It's here so editors can complete boite.json. +boite.schema.json + +# Rolling snapshot of the last config Boite itself wrote, used to recover +# from an outside edit that breaks boite.json. Local to this machine. +.last-good/ + +# Which pane you were last in, and when you last opened this boite. +# Yours, not the project's — a fresh clone correctly has none. +state.json + +.DS_Store + +# Already committed one of the above? Boite won't touch your index: +# git rm -r --cached .boite && git add .boite diff --git a/.boite/F02D376A-B248-47BF-8EB3-A2E246B8B910/boite.json b/.boite/F02D376A-B248-47BF-8EB3-A2E246B8B910/boite.json new file mode 100644 index 0000000..a65c8a8 --- /dev/null +++ b/.boite/F02D376A-B248-47BF-8EB3-A2E246B8B910/boite.json @@ -0,0 +1,42 @@ +{ + "$schema" : "./boite.schema.json", + "schemaVersion" : 14, + "createdAt" : "2026-08-08T08:50:06Z", + "id" : "F02D376A-B248-47BF-8EB3-A2E246B8B910", + "name" : "agentc", + "color" : "system://green", + "icon" : "lucide://container", + "isPinned" : false, + "storageChosenByUser" : false, + "panels" : [ + { + "id" : "ED864585-65F4-40BB-A7A5-03C0BBB48741" + }, + { + "id" : "AE74A2C1-36E5-4C4C-984E-93A61FCB1628" + } + ], + "panelLayout" : { + "children" : [ + { + "id" : "1238D765-FF0A-4C94-9FDB-78D056B3BC07", + "node" : { + "id" : "ED864585-65F4-40BB-A7A5-03C0BBB48741", + "type" : "leaf" + }, + "ratio" : 0.5 + }, + { + "id" : "FF18AE60-8846-43F8-AE91-77731A917C18", + "node" : { + "id" : "AE74A2C1-36E5-4C4C-984E-93A61FCB1628", + "type" : "leaf" + }, + "ratio" : 0.5 + } + ], + "direction" : "horizontal", + "id" : "042D2D85-0C01-421D-B2D7-C229685CD341", + "type" : "branch" + } +} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12cfd5f..3518736 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,121 @@ jobs: - name: Run unit tests run: swift test --disable-default-traits --traits "${{ matrix.traits }}" --filter 'AgentIsolationTests|AgentIsolationDockerRuntimeTests' + # End-to-end coverage for the hardened runtimes the Docker adapter prefers over `runc`. + # Each job installs one runtime on the runner's Docker daemon and then asserts that + # discovery finds it, selects it, and that containers really stop sharing the host kernel. + # + # gVisor needs no hardware support (its default `systrap` platform is pure userspace), so + # it runs as a required job on both architectures. Kata needs `/dev/kvm`. + runtime-e2e: + name: Runtime E2E (${{ matrix.runtime }}, ${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - runtime: runsc + os: ubuntu-24.04 + arch: x64 + required: true + - runtime: runsc + os: ubuntu-24.04-arm + arch: arm64 + required: true + - runtime: kata + os: ubuntu-24.04 + arch: x64 + required: false + + runs-on: ${{ matrix.os }} + continue-on-error: ${{ !matrix.required }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Swift + uses: ./.github/actions/setup-swift + + - name: Cache SPM build artifacts + uses: actions/cache@v4 + with: + path: .build + key: spm-runtime-e2e-${{ matrix.os }}-${{ matrix.arch }}-${{ hashFiles('Package.swift', 'Package.resolved') }} + restore-keys: | + spm-runtime-e2e-${{ matrix.os }}-${{ matrix.arch }}- + spm-unit-${{ matrix.os }}-${{ matrix.arch }}- + + # Kata boots a VM per container, so without KVM it either fails outright or silently + # falls back to QEMU's TCG interpreter, which is far too slow to test against. + - name: Probe KVM + id: kvm + if: matrix.runtime == 'kata' + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm || true + grep -c -E 'vmx|svm' /proc/cpuinfo || echo "no virtualization flags in /proc/cpuinfo" + if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + echo "::notice::/dev/kvm is usable on this runner" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "::warning::/dev/kvm is unavailable on this runner — skipping the Kata E2E" + fi + + - name: Install gVisor + if: matrix.runtime == 'runsc' + run: | + set -euo pipefail + url="https://storage.googleapis.com/gvisor/releases/release/latest/$(uname -m)" + wget -q "${url}/runsc" "${url}/runsc.sha512" \ + "${url}/containerd-shim-runsc-v1" "${url}/containerd-shim-runsc-v1.sha512" + sha512sum -c runsc.sha512 -c containerd-shim-runsc-v1.sha512 + sudo mv runsc containerd-shim-runsc-v1 /usr/local/bin/ + sudo chmod a+rx /usr/local/bin/runsc /usr/local/bin/containerd-shim-runsc-v1 + # Registers a Docker runtime named `runsc` in /etc/docker/daemon.json. + sudo /usr/local/bin/runsc install + sudo systemctl restart docker + + - name: Install Kata Containers + if: matrix.runtime == 'kata' && steps.kvm.outputs.available == 'true' + run: | + set -euo pipefail + # Release asset names have shifted across Kata 3.x (kata-static, kata-go-static, + # .tar.xz, .tar.zst), so pick whatever the latest release actually publishes. + asset=$(curl -fsSL https://api.github.com/repos/kata-containers/kata-containers/releases/latest \ + | jq -r '.assets[].browser_download_url + | select(test("kata(-go)?-static-.*(amd64|x86_64)\\.tar\\.(xz|zst)$"))' \ + | head -n1) + test -n "$asset" || { echo "::error::no Kata static release asset found"; exit 1; } + echo "Installing $asset" + sudo apt-get update && sudo apt-get install -y zstd + curl -fsSL -o kata-static.tar "$asset" + sudo tar -xf kata-static.tar -C / + /opt/kata/bin/kata-runtime --version + + # Register the shim as a Docker runtime, merging into any existing daemon.json. + sudo mkdir -p /etc/docker + [ -f /etc/docker/daemon.json ] || echo '{}' | sudo tee /etc/docker/daemon.json >/dev/null + jq '.runtimes.kata = {"runtimeType": "/opt/kata/bin/containerd-shim-kata-v2"}' \ + /etc/docker/daemon.json | sudo tee /etc/docker/daemon.json.new >/dev/null + sudo mv /etc/docker/daemon.json.new /etc/docker/daemon.json + sudo systemctl restart docker + + - name: Verify the runtime is registered with Docker + if: matrix.runtime != 'kata' || steps.kvm.outputs.available == 'true' + run: | + set -euo pipefail + docker info --format 'default={{.DefaultRuntime}} runtimes={{json .Runtimes}}' + docker info --format '{{json .Runtimes}}' | jq -e 'has("${{ matrix.runtime }}")' + docker run --rm --runtime '${{ matrix.runtime }}' alpine:latest uname -r + + - name: Run runtime E2E tests + if: matrix.runtime != 'kata' || steps.kvm.outputs.available == 'true' + run: swift test --disable-default-traits --traits ContainerRuntimeDocker --filter AgentIsolationDockerRuntimeTests + env: + AGENTC_TEST_DOCKER_RUNTIME: ${{ matrix.runtime }} + build-test-image: strategy: fail-fast: false diff --git a/README.md b/README.md index a17b7f1..3312a81 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,23 @@ To skip the bootstrap and use the image's own entrypoint: agentc run --respect-image-entrypoint -i my-image:latest ``` +### Container Isolation (Docker runtime) + +Agents run code you did not write, so on the Docker backend `agentc` asks the daemon which +runtimes it has and prefers the strongest isolation available: **Kata Containers** (a VM per +container) over **gVisor** (`runsc`) over **`runc`** (shares the host kernel). If only `runc` +is available, `agentc` uses it and prints a one-time warning with setup instructions. + +Pick one yourself — including `runc`, which also silences the warning: + +```sh +agentc run --docker-runtime runsc +agentc run --docker-runtime runc # "I know, runc is fine here" +``` + +See [docs/docker-runtimes.md](./docs/docker-runtimes.md). The Apple Container runtime +already gives every container its own VM, so this does not apply on macOS. + ## Architecture ``` diff --git a/Sources/AgentIsolation/ContainerRuntime.swift b/Sources/AgentIsolation/ContainerRuntime.swift index 8dbe7b9..5364933 100644 --- a/Sources/AgentIsolation/ContainerRuntime.swift +++ b/Sources/AgentIsolation/ContainerRuntime.swift @@ -70,9 +70,30 @@ public struct ContainerRuntimeConfiguration: Sendable { public var storagePath: String public var endpoint: String? - public init(storagePath: String, endpoint: String? = nil) { + /// An explicit low-level runtime (OCI runtime binary or containerd shim) to run + /// containers with — Docker's `HostConfig.Runtime`, for example. + /// + /// Runtime names are administrator-defined aliases, so this is an opaque value passed + /// through to the runtime unvalidated. When `nil`, a conforming runtime is free to pick + /// one itself; ``AgentIsolationDockerRuntime`` discovers what the daemon offers and + /// prefers the strongest isolation available. + public var ociRuntime: String? + + /// Invoked with user-facing security or configuration warnings raised while setting up + /// the runtime. Messages are multi-line and pre-formatted; the host decides where they + /// go. When `nil`, the runtime falls back to its logger. + public var warningHandler: (@Sendable (String) -> Void)? + + public init( + storagePath: String, + endpoint: String? = nil, + ociRuntime: String? = nil, + warningHandler: (@Sendable (String) -> Void)? = nil + ) { self.storagePath = storagePath self.endpoint = endpoint + self.ociRuntime = ociRuntime + self.warningHandler = warningHandler } } diff --git a/Sources/AgentIsolation/ProjectSettings.swift b/Sources/AgentIsolation/ProjectSettings.swift index ce1dbc4..f14c787 100644 --- a/Sources/AgentIsolation/ProjectSettings.swift +++ b/Sources/AgentIsolation/ProjectSettings.swift @@ -11,9 +11,26 @@ /// only the values you specify take effect. public struct ProjectSettings: Codable, Sendable, Equatable { public var agent: AgentSettings? + public var docker: DockerSettings? - public init(agent: AgentSettings? = nil) { + public init(agent: AgentSettings? = nil, docker: DockerSettings? = nil) { self.agent = agent + self.docker = docker + } + + /// Settings specific to the Docker runtime backend. + public struct DockerSettings: Codable, Sendable, Equatable { + /// The runtime to run containers with, e.g. `kata`, `runsc`, or `runc`. + /// + /// Runtime names are administrator-defined aliases and Docker can also invoke + /// fully-qualified containerd shims that are not registered with the daemon, so this + /// value is passed through unvalidated. Setting it also opts out of the security + /// warning shown when only `runc` is available. + public var runtime: String? + + public init(runtime: String? = nil) { + self.runtime = runtime + } } public struct AgentSettings: Codable, Sendable, Equatable { diff --git a/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift b/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift index 2718315..d18b931 100644 --- a/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift +++ b/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift @@ -92,6 +92,22 @@ final class DockerAPIClient: Sendable { } } + // MARK: - Daemon Info + + /// Fetch `GET /info` to discover the runtimes registered with the daemon. + func info() async throws -> DockerInfo { + var request = makeRequest(url: buildURL(path: "/info")) + request.method = .GET + + let response = try await httpClient.execute(request, timeout: .seconds(30)) + let body = try await response.body.collect(upTo: 10 * 1024 * 1024) + guard response.status == .ok else { + throw DockerRuntimeError.apiError( + Int(response.status.code), "Failed to read daemon info: \(String(buffer: body))") + } + return try JSONDecoder().decode(DockerInfo.self, from: body) + } + // MARK: - Image Operations func pullImage(ref: String, platform: String? = nil) async throws { diff --git a/Sources/AgentIsolationDockerRuntime/DockerModels.swift b/Sources/AgentIsolationDockerRuntime/DockerModels.swift index f52a165..daf04bd 100644 --- a/Sources/AgentIsolationDockerRuntime/DockerModels.swift +++ b/Sources/AgentIsolationDockerRuntime/DockerModels.swift @@ -4,6 +4,31 @@ import Foundation #endif +// MARK: - Daemon Info + +/// The subset of `GET /info` we use to discover which runtimes the daemon can invoke. +/// +/// Docker reports every runtime registered in `daemon.json` (plus the built-in `runc`). +/// It can additionally invoke fully-qualified containerd shims found on its `PATH` without +/// registering them, so absence from `Runtimes` does not mean a runtime is unusable — +/// hence ``DockerRuntimeSelection/Source/configured``. +struct DockerInfo: Codable, Sendable { + let Runtimes: [String: RuntimeEntry]? + let DefaultRuntime: String? + + /// A registered runtime. `path` is set for OCI runtime binaries, `runtimeType` for + /// containerd shims — either one can identify a runtime hiding behind an opaque alias. + struct RuntimeEntry: Codable, Sendable { + var path: String? + var runtimeType: String? + + init(path: String? = nil, runtimeType: String? = nil) { + self.path = path + self.runtimeType = runtimeType + } + } +} + // MARK: - Image Types struct DockerImageInspect: Codable, Sendable { @@ -47,6 +72,8 @@ struct DockerHostConfig: Codable, Sendable { var NanoCpus: Int64? var CpusetCpus: String? var Init: Bool? + /// The runtime to run this container with. Left `nil` to use the daemon's default. + var Runtime: String? } struct DockerCreateContainerResponse: Codable, Sendable { diff --git a/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift b/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift index 10aa6d1..759540d 100644 --- a/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift +++ b/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift @@ -37,9 +37,17 @@ public final class DockerRuntime: ContainerRuntime, Sendable { private let endpoint: String private let logger = Logger(label: "com.agentc.docker-runtime") + /// An explicit runtime alias from configuration. Honored verbatim, never validated. + private let configuredRuntime: String? + private let warningHandler: (@Sendable (String) -> Void)? + /// Resolved once and reused, so the warning is shown at most once per runtime. + private let selection = Mutex(nil) + public required init(config: ContainerRuntimeConfiguration) { self.endpoint = config.endpoint ?? Self.autoDetectEndpoint() self.client = DockerAPIClient(endpoint: self.endpoint) + self.configuredRuntime = config.ociRuntime + self.warningHandler = config.warningHandler } /// Auto-detect the Docker socket path by checking common locations. @@ -82,6 +90,51 @@ public final class DockerRuntime: ContainerRuntime, Sendable { public func prepare() async throws { try await client.ping() + await resolveRuntimeSelection() + } + + // MARK: - Runtime selection + + /// The runtime this instance runs containers with, resolved on first use. + /// + /// Kata is preferred over gVisor over `runc`; see ``DockerRuntimeSelection/select(configured:info:)``. + public func selectedRuntime() async -> DockerRuntimeSelection { + await resolveRuntimeSelection() + } + + @discardableResult + private func resolveRuntimeSelection() async -> DockerRuntimeSelection { + if let cached = selection.withLock({ $0 }) { return cached } + + // An explicit configuration is taken at face value, so there is nothing to discover. + // It may well name a containerd shim the daemon never registered. + var info: DockerInfo? + if configuredRuntime == nil { + do { + info = try await client.info() + } catch { + // Discovery is advisory: a daemon that won't describe itself still runs containers. + logger.debug("Failed to read Docker daemon info: \(error)") + } + } + + let resolved = DockerRuntimeSelection.select(configured: configuredRuntime, info: info) + let isFirstResolution = selection.withLock { stored -> Bool in + guard stored == nil else { return false } + stored = resolved + return true + } + + guard isFirstResolution else { return selection.withLock { $0 } ?? resolved } + + if let warning = resolved.warning { + if let warningHandler { + warningHandler(warning) + } else { + logger.warning("\(warning)") + } + } + return resolved } /// Shut down the HTTP client. Call when the runtime is no longer needed. @@ -147,9 +200,13 @@ public final class DockerRuntime: ContainerRuntime, Sendable { /// `Env` is left `nil` when no variables are set, so the image's own `ENV` is untouched. /// Otherwise the daemon merges these entries over the image's by name, which is why we /// pass them through as-is rather than reconciling anything here. + /// + /// `runtimeName` is the only isolation-relevant field we set; a `nil` leaves + /// `HostConfig.Runtime` out of the payload so the daemon applies its own default. static func makeCreateRequest( imageRef: String, - configuration: ContainerConfiguration + configuration: ContainerConfiguration, + runtimeName: String? = nil ) -> DockerCreateContainerRequest { // Build bind mounts var binds: [String] = [] @@ -185,7 +242,8 @@ public final class DockerRuntime: ContainerRuntime, Sendable { Memory: Int64(configuration.memoryLimitMiB) * 1024 * 1024, NanoCpus: Int64(configuration.cpuCount) * 1_000_000_000, CpusetCpus: "0-\(configuration.cpuCount - 1)", - Init: true + Init: true, + Runtime: runtimeName ) ) } @@ -196,10 +254,18 @@ public final class DockerRuntime: ContainerRuntime, Sendable { ) async throws -> DockerContainer { let useTTY = Self.usesTTY(for: configuration.io) + let runtime = await resolveRuntimeSelection() let createConfig = Self.makeCreateRequest( - imageRef: imageRef, configuration: configuration) + imageRef: imageRef, configuration: configuration, runtimeName: runtime.name) - let containerId = try await client.createContainer(config: createConfig) + let containerId: String + do { + containerId = try await client.createContainer(config: createConfig) + } catch { + // Never retry on the daemon's default: a misconfigured Kata/gVisor runtime would + // otherwise silently drop the isolation boundary the user is relying on. + throw Self.surfaceRuntimeFailure(error, runtime: runtime) + } // Set up terminal for TTY mode — only when using the actual current terminal var terminalState: DockerTerminalState? @@ -255,6 +321,30 @@ public final class DockerRuntime: ContainerRuntime, Sendable { ) } + /// Wrap a create failure that is attributable to the selected runtime, so the user sees + /// *which* runtime the daemon rejected — and that nothing was retried without it — + /// rather than a bare "failed to create container". + /// + /// Plain `runc` selections are left alone: there is no isolation decision to explain, and + /// the underlying error stands on its own. So are failures the daemon blames on something + /// else (a missing image, a bad mount), which would only be muddied by runtime talk. + static func surfaceRuntimeFailure( + _ error: any Error, runtime: DockerRuntimeSelection + ) -> any Error { + guard let name = runtime.name, runtime.kind != .standard, + case DockerRuntimeError.apiError(_, let message) = error + else { + return error + } + // Docker's wording varies ("unknown or invalid runtime name", "failed to start shim"), + // but a runtime rejection always names one of the two. + let lowercased = message.lowercased() + guard lowercased.contains("runtime") || lowercased.contains("shim") else { + return error + } + return DockerRuntimeError.runtimeUnavailable(runtime: name, reason: message) + } + public func removeContainer(_ container: DockerContainer) async throws { container.terminalState?.restore() container.attachConnection?.stop() @@ -374,6 +464,8 @@ public enum DockerRuntimeError: LocalizedError { case apiError(Int, String) case attachFailed(String) case socketError(String) + /// The daemon refused to create a container with the selected non-default runtime. + case runtimeUnavailable(runtime: String, reason: String) public var errorDescription: String? { switch self { @@ -389,6 +481,13 @@ public enum DockerRuntimeError: LocalizedError { return "Failed to attach to container: \(msg)" case .socketError(let msg): return "Socket error: \(msg)" + case .runtimeUnavailable(let runtime, let reason): + return """ + Docker refused to create a container with the `\(runtime)` runtime: \(reason) + Falling back to the default runtime would remove the isolation boundary this \ + runtime was chosen for, so the container was not started. Fix the runtime on the \ + Docker host, or select a different one with `--docker-runtime `. + """ } } } diff --git a/Sources/AgentIsolationDockerRuntime/DockerRuntimeSelection.swift b/Sources/AgentIsolationDockerRuntime/DockerRuntimeSelection.swift new file mode 100644 index 0000000..80141b9 --- /dev/null +++ b/Sources/AgentIsolationDockerRuntime/DockerRuntimeSelection.swift @@ -0,0 +1,264 @@ +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + +// MARK: - DockerRuntimeKind + +/// The isolation class of a runtime registered with the Docker daemon. +/// +/// Docker runtime names are administrator-defined aliases, so this is a best-effort +/// classification of an alias (and, when the daemon reports them, of the shim type and +/// binary path behind it). Anything we cannot place lands in ``unknown`` — we never +/// assume an unrecognized alias is weak *or* strong. +public enum DockerRuntimeKind: String, Sendable, Equatable, CaseIterable { + /// Kata Containers — each container gets its own lightweight VM. + case kata + /// gVisor (`runsc`) — an application kernel that services syscalls in userspace. + case gVisor + /// `runc` and friends — containers share the host kernel. + case standard + /// An administrator-defined alias we cannot classify. + case unknown + + /// Whether this kind adds an isolation boundary beyond the host kernel. + var isHardened: Bool { + self == .kata || self == .gVisor + } +} + +// MARK: - DockerRuntimeSelection + +/// The runtime chosen for container creation, and how we arrived at it. +/// +/// Produced once per ``DockerRuntime`` from the daemon's `GET /info` response (or straight +/// from configuration) and then applied to every container as `HostConfig.Runtime`. +public struct DockerRuntimeSelection: Sendable, Equatable { + + /// How the runtime was picked. + public enum Source: Sendable, Equatable { + /// The user named it explicitly. Taken at face value, never second-guessed. + case configured + /// Discovered among the daemon's registered runtimes. + case discovered + /// Nothing usable was discovered; the daemon's own default applies. + case daemonDefault + } + + /// The value to send as `HostConfig.Runtime`, or `nil` to let the daemon pick. + public var name: String? + public var kind: DockerRuntimeKind + public var source: Source + /// Registered runtime names we could not classify. Surfaced in the warning so an + /// administrator who aliased a hardened runtime knows what to configure. + public var unclassifiedNames: [String] + + init( + name: String?, + kind: DockerRuntimeKind, + source: Source, + unclassifiedNames: [String] = [] + ) { + self.name = name + self.kind = kind + self.source = source + self.unclassifiedNames = unclassifiedNames + } + + // MARK: - Classification + + /// Aliases we treat as canonical for a kind, in preference order. Used to break ties + /// when a host registers several runtimes of the same kind (e.g. `kata-qemu` + `kata-clh`). + private static let canonicalNames: [DockerRuntimeKind: [String]] = [ + .kata: ["kata", "kata-runtime", "kata-qemu"], + .gVisor: ["runsc", "gvisor"], + .standard: ["runc"], + ] + + /// Reduce a token to the runtime family it names. + /// + /// The same runtime shows up in `GET /info` in several spellings depending on how the + /// administrator registered it — `kata`, `io.containerd.kata.v2`, or the shim binary at + /// `/opt/kata/bin/containerd-shim-kata-v2` (the form Kata's own Docker guide uses). + /// Peeling off the directory, the `containerd-shim-` wrapper, the `io.containerd.` + /// namespace, and the trailing shim version collapses them all to `kata`. + private static func normalize(_ token: String) -> String { + var name = token.lowercased() + + if let slash = name.lastIndex(of: "/") { + name = String(name[name.index(after: slash)...]) + } + if name.hasPrefix("containerd-shim-") { + name.removeFirst("containerd-shim-".count) + } + if name.hasPrefix("io.containerd.") { + name.removeFirst("io.containerd.".count) + } + // Trailing shim version: `kata-v2`, `runsc.v1`. + if let separator = name.lastIndex(where: { $0 == "-" || $0 == "." }) { + let suffix = name[name.index(after: separator)...] + if suffix.first == "v", suffix.dropFirst().allSatisfy(\.isNumber), suffix.count > 1 { + name = String(name[.. DockerRuntimeKind { + let name = normalize(token) + + // Kata registers as `kata`/`kata-runtime`, plus per-hypervisor aliases such as + // `kata-qemu`, `kata-clh`, `kata-fc`. + if name == "kata" || name.hasPrefix("kata-") { return .kata } + + if name == "runsc" || name == "gvisor" || name.hasPrefix("runsc-") + || name.hasPrefix("gvisor-") + { + return .gVisor + } + + // Exact match only, so lookalikes such as `crun` and `sysbox-runc` stay unclassified + // rather than being reported as the runtime we know to be weakest. + if name == "runc" { return .standard } + + return .unknown + } + + /// Classify a registered runtime, falling back to the shim type and then the binary path + /// when the alias itself is opaque. `runsc install --runtime sandbox`, for instance, + /// yields the alias `sandbox` with `path` pointing at the `runsc` binary. + static func classify(name: String, entry: DockerInfo.RuntimeEntry?) -> DockerRuntimeKind { + var kind = classify(token: name) + if kind != .unknown { return kind } + + if let runtimeType = entry?.runtimeType, !runtimeType.isEmpty { + kind = classify(token: runtimeType) + if kind != .unknown { return kind } + } + + if let path = entry?.path, !path.isEmpty { + let binary = path.split(separator: "/").last.map(String.init) ?? path + kind = classify(token: binary) + if kind != .unknown { return kind } + } + + return .unknown + } + + // MARK: - Selection + + /// Choose a runtime: an explicit configuration wins, otherwise Kata > gVisor > `runc`. + /// + /// - Parameters: + /// - configured: An opaque, administrator-defined runtime alias from configuration. + /// Used verbatim without validation — Docker can invoke fully-qualified containerd + /// shims from the daemon's `PATH` that never appear in the registered-runtime list. + /// - info: The daemon's `GET /info` response, or `nil` when it could not be read. + static func select(configured: String?, info: DockerInfo?) -> DockerRuntimeSelection { + if let configured, !configured.trimmingCharacters(in: .whitespaces).isEmpty { + let name = configured.trimmingCharacters(in: .whitespaces) + return .init(name: name, kind: classify(token: name), source: .configured) + } + + // The default runtime is normally listed in `Runtimes` too, but fold it in so a daemon + // that reports one without the other still gets classified. + var entries: [String: DockerInfo.RuntimeEntry] = info?.Runtimes ?? [:] + if let fallback = info?.DefaultRuntime, !fallback.isEmpty, entries[fallback] == nil { + entries[fallback] = DockerInfo.RuntimeEntry() + } + + guard !entries.isEmpty else { + return .init(name: nil, kind: .unknown, source: .daemonDefault) + } + + var byKind: [DockerRuntimeKind: [String]] = [:] + for (name, entry) in entries { + byKind[classify(name: name, entry: entry), default: []].append(name) + } + let unclassified = (byKind[.unknown] ?? []).sorted() + + for kind in [DockerRuntimeKind.kata, .gVisor, .standard] { + guard let name = preferred(among: byKind[kind] ?? [], kind: kind) else { continue } + return .init( + name: name, + kind: kind, + source: .discovered, + unclassifiedNames: kind == .standard ? unclassified : [] + ) + } + + // Every registered runtime is an alias we don't recognize. + return .init( + name: nil, kind: .unknown, source: .daemonDefault, unclassifiedNames: unclassified) + } + + /// Pick one name out of several of the same kind: a canonical alias if present, otherwise + /// the lexicographically first, so the choice is stable across runs. + private static func preferred(among names: [String], kind: DockerRuntimeKind) -> String? { + guard !names.isEmpty else { return nil } + for canonical in canonicalNames[kind] ?? [] { + if let match = names.first(where: { $0.lowercased() == canonical }) { + return match + } + } + return names.sorted().first + } + + // MARK: - Warning + + /// A security warning to show the user, or `nil` when the selection needs no comment. + /// + /// Nothing is emitted for an explicit configuration — including an explicit `runc`, which + /// is the documented way to silence this — or when a hardened runtime was found. + public var warning: String? { + guard source != .configured, !kind.isHardened else { return nil } + + var lines: [String] = [] + switch kind { + case .standard: + lines.append( + """ + This Docker host only exposes the standard `runc` runtime. Because this \ + application executes custom code, we recommend installing Kata Containers or \ + gVisor for stronger workload isolation. The application will continue using \ + `runc` unless configured otherwise. + """) + default: + lines.append( + """ + Could not determine which container runtimes this Docker host provides, so the \ + daemon's default runtime will be used. If that default is the standard `runc` \ + runtime, custom code runs directly against the host kernel. We recommend \ + installing Kata Containers or gVisor for stronger workload isolation. + """) + } + + if !unclassifiedNames.isEmpty { + lines.append( + """ + + This host also registers runtimes that are not recognized: \ + \(unclassifiedNames.joined(separator: ", ")). If one of them provides stronger \ + isolation, select it explicitly and it will be used as-is. + """) + } + + lines.append( + """ + + Setup guides: + Kata Containers https://github.com/kata-containers/kata-containers/blob/main/docs/installation.md + gVisor / runsc https://gvisor.dev/docs/user_guide/quick_start/docker/ + Docker runtimes https://docs.docker.com/engine/daemon/alternative-runtimes/ + dockerd reference https://docs.docker.com/reference/cli/dockerd/ + + To choose a runtime — and to silence this warning, including when you have decided \ + `runc` is fine — pass `--docker-runtime ` or set `"docker": {"runtime": \ + ""}` in .agentc/settings.json. For example: `--docker-runtime runc`. + """) + + return lines.joined(separator: "\n") + } +} diff --git a/Sources/agentc/SessionRunner.swift b/Sources/agentc/SessionRunner.swift index 76b2fbb..81280ad 100644 --- a/Sources/agentc/SessionRunner.swift +++ b/Sources/agentc/SessionRunner.swift @@ -75,7 +75,8 @@ enum SessionRunner { ) return try await dispatchToRuntime( - options: options, config: isolationConfig, entrypoint: entrypoint) + options: options, config: isolationConfig, entrypoint: entrypoint, + projectSettings: projectSettings) } // MARK: - Runtime dispatch @@ -83,7 +84,8 @@ enum SessionRunner { private static func dispatchToRuntime( options: SharedOptions, config: IsolationConfig, - entrypoint: [String]? + entrypoint: [String]?, + projectSettings: ProjectSettings? ) async throws -> Int32 { let storagePath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) @@ -92,7 +94,13 @@ enum SessionRunner { .path let runtimeConfig = ContainerRuntimeConfiguration( - storagePath: storagePath, endpoint: options.dockerEndpoint) + storagePath: storagePath, + endpoint: options.dockerEndpoint, + ociRuntime: options.resolveDockerRuntime(projectSettings: projectSettings), + warningHandler: { message in + // stderr, so the warning never lands in output the agent's caller is parsing. + writeToStderr("\nagentc: \(message)\n\n") + }) let choice = RuntimeChoice.resolve(explicit: options.runtime) return switch choice { diff --git a/Sources/agentc/SharedOptions.swift b/Sources/agentc/SharedOptions.swift index 779d291..fa11013 100644 --- a/Sources/agentc/SharedOptions.swift +++ b/Sources/agentc/SharedOptions.swift @@ -101,6 +101,15 @@ struct SharedOptions: ParsableArguments { @Option(name: .long, help: "Docker Engine API endpoint (socket path or tcp://host:port).") var dockerEndpoint: String? + @Option( + name: .long, + help: ArgumentHelp( + "Docker runtime to run the container with (e.g. kata, runsc, runc). " + + "Defaults to the strongest isolation the daemon offers.", + valueName: "name") + ) + var dockerRuntime: String? + @Option( name: .customLong("cpus"), help: "Number of CPUs to allocate to the container (default: 1).") @@ -234,6 +243,19 @@ extension SharedOptions { image ?? projectSettings?.agent?.image ?? "ghcr.io/laosb/claudec:latest" } + /// Resolve the explicit Docker runtime. CLI flag → project settings → nil (auto-select). + /// + /// Returns the value verbatim — runtime names are administrator-defined aliases, and + /// Docker can invoke containerd shims that never appear in its registered-runtime list, + /// so there is nothing here we could meaningfully validate against. + func resolveDockerRuntime(projectSettings: ProjectSettings? = nil) -> String? { + let value = dockerRuntime ?? projectSettings?.docker?.runtime + guard let trimmed = value?.trimmingCharacters(in: .whitespaces), !trimmed.isEmpty else { + return nil + } + return trimmed + } + /// Resolve CPU count. CLI flag → project settings → 1. func resolveCpuCount(projectSettings: ProjectSettings? = nil) -> Int { cpuCount ?? projectSettings?.agent?.cpus ?? 1 diff --git a/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeSelectionTests.swift b/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeSelectionTests.swift new file mode 100644 index 0000000..1429954 --- /dev/null +++ b/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeSelectionTests.swift @@ -0,0 +1,609 @@ +#if ContainerRuntimeDocker + import AgentIsolation + @testable import AgentIsolationDockerRuntime + import Foundation + import Testing + + // MARK: - Classification + + @Suite("DockerRuntimeSelection Classification") + struct DockerRuntimeClassificationTests { + + @Test( + "Kata aliases and shims classify as kata", + arguments: [ + "kata", "Kata", "kata-runtime", "kata-qemu", "kata-clh", "kata-fc", + "io.containerd.kata.v2", "io.containerd.kata-qemu.v2", + // The form Kata's own Docker guide tells administrators to register. + "/opt/kata/bin/containerd-shim-kata-v2", + ]) + func kataAliases(name: String) { + #expect(DockerRuntimeSelection.classify(token: name) == .kata) + } + + @Test( + "gVisor aliases and shims classify as gVisor", + arguments: [ + "runsc", "RunSC", "gvisor", "runsc-kvm", "io.containerd.runsc.v1", + "/usr/local/bin/containerd-shim-runsc-v1", + ]) + func gVisorAliases(name: String) { + #expect(DockerRuntimeSelection.classify(token: name) == .gVisor) + } + + @Test( + "runc aliases and shims classify as standard", + arguments: [ + "runc", "io.containerd.runc.v2", "io.containerd.runc", "/usr/bin/runc", + ]) + func standardAliases(name: String) { + #expect(DockerRuntimeSelection.classify(token: name) == .standard) + } + + /// Guessing wrong in either direction is worse than admitting we don't know: calling an + /// unknown runtime `standard` cries wolf, calling it hardened hides a real risk. + @Test( + "Unrecognized names stay unknown rather than being guessed", + arguments: ["crun", "sandbox", "youki", "sysbox-runc", "", "io.containerd.runhcs.v1"]) + func unknownAliases(name: String) { + #expect(DockerRuntimeSelection.classify(token: name) == .unknown) + } + + @Test("An opaque alias is classified by its containerd shim type") + func classifyByShimType() { + let kind = DockerRuntimeSelection.classify( + name: "secure-vm", entry: .init(runtimeType: "io.containerd.kata.v2")) + #expect(kind == .kata) + } + + /// Kata's Docker guide registers `runtimeType` as an absolute path to the shim binary + /// rather than the shim's reverse-DNS name. + @Test("An opaque alias is classified by a shim registered as a binary path") + func classifyByShimPath() { + let kind = DockerRuntimeSelection.classify( + name: "secure-vm", entry: .init(runtimeType: "/opt/kata/bin/containerd-shim-kata-v2")) + #expect(kind == .kata) + } + + @Test("An opaque alias is classified by its runtime binary path") + func classifyByBinaryPath() { + let kind = DockerRuntimeSelection.classify( + name: "sandbox", entry: .init(path: "/usr/local/bin/runsc")) + #expect(kind == .gVisor) + } + + @Test("An alias with no usable hints stays unknown") + func classifyWithoutHints() { + let kind = DockerRuntimeSelection.classify( + name: "sandbox", entry: .init(path: "/opt/vendor/bin/sandboxd")) + #expect(kind == .unknown) + } + } + + // MARK: - Selection + + @Suite("DockerRuntimeSelection Selection") + struct DockerRuntimeSelectionTests { + + private func info( + _ runtimes: [String: DockerInfo.RuntimeEntry], default defaultRuntime: String? = "runc" + ) -> DockerInfo { + // Round-trip through JSON so the tests exercise the decoder we actually ship. + let payload: [String: Any] = [ + "Runtimes": runtimes.mapValues { entry -> [String: Any] in + var value: [String: Any] = [:] + if let path = entry.path { value["path"] = path } + if let type = entry.runtimeType { value["runtimeType"] = type } + // Fields we deliberately ignore, present on every real daemon response. + value["status"] = ["org.opencontainers.runtime-spec.features": "{}"] + return value + }, + "DefaultRuntime": defaultRuntime as Any, + "ServerVersion": "27.0.0", + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + return try! JSONDecoder().decode(DockerInfo.self, from: data) + } + + // MARK: Explicit configuration + + @Test("An explicit runtime wins over anything discoverable") + func configuredWins() { + let selection = DockerRuntimeSelection.select( + configured: "runc", info: info(["runc": .init(), "kata": .init(), "runsc": .init()])) + #expect(selection.name == "runc") + #expect(selection.kind == .standard) + #expect(selection.source == .configured) + } + + /// The documented escape hatch: choosing `runc` on purpose is a decision, not a mistake. + @Test("An explicit runc choice silences the warning") + func configuredRuncIsNotWarnedAbout() { + let selection = DockerRuntimeSelection.select( + configured: "runc", info: info(["runc": .init()])) + #expect(selection.warning == nil) + } + + @Test("An unrecognized explicit alias is used as-is and not questioned") + func configuredCustomAlias() { + let selection = DockerRuntimeSelection.select( + configured: "io.containerd.my-vendor-shim.v1", info: nil) + #expect(selection.name == "io.containerd.my-vendor-shim.v1") + #expect(selection.kind == .unknown) + #expect(selection.source == .configured) + #expect(selection.warning == nil) + } + + @Test("Surrounding whitespace is trimmed from an explicit alias") + func configuredIsTrimmed() { + #expect(DockerRuntimeSelection.select(configured: " runsc ", info: nil).name == "runsc") + } + + @Test("A blank explicit alias falls through to discovery") + func blankConfiguredFallsThrough() { + let selection = DockerRuntimeSelection.select( + configured: " ", info: info(["runc": .init(), "runsc": .init()])) + #expect(selection.name == "runsc") + #expect(selection.source == .discovered) + } + + // MARK: Preference order + + @Test("Kata is preferred over gVisor and runc") + func prefersKata() { + let selection = DockerRuntimeSelection.select( + configured: nil, info: info(["runc": .init(), "runsc": .init(), "kata": .init()])) + #expect(selection.name == "kata") + #expect(selection.kind == .kata) + #expect(selection.source == .discovered) + #expect(selection.warning == nil) + } + + @Test("gVisor is preferred over runc when Kata is absent") + func prefersGVisor() { + let selection = DockerRuntimeSelection.select( + configured: nil, info: info(["runc": .init(), "runsc": .init()])) + #expect(selection.name == "runsc") + #expect(selection.kind == .gVisor) + #expect(selection.warning == nil) + } + + @Test("A hardened runtime hiding behind an opaque alias is still found") + func findsAliasedHardenedRuntime() { + let selection = DockerRuntimeSelection.select( + configured: nil, + info: info([ + "runc": .init(path: "/usr/bin/runc"), + "vm": .init(runtimeType: "io.containerd.kata.v2"), + ])) + #expect(selection.name == "vm") + #expect(selection.kind == .kata) + } + + /// Several runtimes of one kind must not make the choice depend on dictionary order. + @Test("Ties are broken deterministically, preferring canonical names") + func deterministicTieBreak() { + let entries = ["kata-clh": DockerInfo.RuntimeEntry(), "kata-qemu": .init(), "kata": .init()] + for _ in 0..<20 { + #expect(DockerRuntimeSelection.select(configured: nil, info: info(entries)).name == "kata") + } + let noCanonical = ["kata-fc": DockerInfo.RuntimeEntry(), "kata-clh": .init()] + for _ in 0..<20 { + #expect( + DockerRuntimeSelection.select(configured: nil, info: info(noCanonical)).name == "kata-clh" + ) + } + } + + @Test("The default runtime is considered even when absent from the runtimes map") + func defaultRuntimeIsFoldedIn() { + let selection = DockerRuntimeSelection.select( + configured: nil, info: info([:], default: "kata")) + #expect(selection.name == "kata") + #expect(selection.kind == .kata) + } + + // MARK: runc-only warning + + @Test("A runc-only host selects runc and warns") + func runcOnlyWarns() throws { + let selection = DockerRuntimeSelection.select( + configured: nil, info: info(["runc": .init(path: "/usr/bin/runc")])) + #expect(selection.name == "runc") + #expect(selection.kind == .standard) + #expect(selection.source == .discovered) + + let warning = try #require(selection.warning) + #expect(warning.contains("only exposes the standard `runc` runtime")) + #expect(warning.contains("kata-containers/kata-containers")) + #expect(warning.contains("gvisor.dev/docs/user_guide/quick_start/docker/")) + #expect(warning.contains("docs.docker.com/engine/daemon/alternative-runtimes/")) + #expect(warning.contains("docs.docker.com/reference/cli/dockerd/")) + // The warning has to carry its own opt-out, or it is just noise the user can't act on. + #expect(warning.contains("--docker-runtime runc")) + } + + @Test("Unrecognized runtimes on a runc-only host are named in the warning") + func warningNamesUnclassifiedRuntimes() throws { + let selection = DockerRuntimeSelection.select( + configured: nil, info: info(["runc": .init(), "crun": .init(), "youki": .init()])) + #expect(selection.name == "runc") + #expect(selection.unclassifiedNames == ["crun", "youki"]) + + let warning = try #require(selection.warning) + #expect(warning.contains("crun, youki")) + } + + // MARK: Discovery failures + + @Test("An unreadable daemon info falls back to the daemon default and warns") + func noInfoUsesDaemonDefault() throws { + let selection = DockerRuntimeSelection.select(configured: nil, info: nil) + #expect(selection.name == nil) + #expect(selection.source == .daemonDefault) + + let warning = try #require(selection.warning) + #expect(warning.contains("Could not determine")) + // We must not claim to know the host is runc-only when we could not look. + #expect(!warning.contains("only exposes")) + #expect(warning.contains("--docker-runtime")) + } + + @Test("A host with only unrecognized runtimes defers to the daemon default and warns") + func onlyUnknownRuntimes() throws { + let selection = DockerRuntimeSelection.select( + configured: nil, info: info(["youki": .init()], default: "youki")) + #expect(selection.name == nil) + #expect(selection.kind == .unknown) + #expect(selection.source == .daemonDefault) + + let warning = try #require(selection.warning) + #expect(warning.contains("youki")) + } + } + + // MARK: - Create Request + + @Suite("DockerRuntime Create Request Runtime") + struct DockerCreateRequestRuntimeTests { + + private func request(runtimeName: String?) -> DockerCreateContainerRequest { + DockerRuntime.makeCreateRequest( + imageRef: "alpine:latest", + configuration: ContainerConfiguration(entrypoint: ["echo", "hi"], io: .standardIO), + runtimeName: runtimeName) + } + + @Test("The selected runtime is sent as HostConfig.Runtime") + func runtimeIsSent() throws { + #expect(request(runtimeName: "runsc").HostConfig?.Runtime == "runsc") + let json = String( + decoding: try JSONEncoder().encode(request(runtimeName: "runsc")), as: UTF8.self) + #expect(json.contains("\"Runtime\":\"runsc\"")) + } + + @Test("HostConfig.Runtime is omitted entirely when no runtime was selected") + func runtimeOmitted() throws { + #expect(request(runtimeName: nil).HostConfig?.Runtime == nil) + let json = String( + decoding: try JSONEncoder().encode(request(runtimeName: nil)), as: UTF8.self) + #expect(!json.contains("\"Runtime\"")) + } + + /// The rest of the create payload is what it always was — runtime selection is the + /// only thing this change touches. + @Test("Selecting a runtime changes nothing else in the payload") + func onlyRuntimeChanges() throws { + let with = request(runtimeName: "kata") + let without = request(runtimeName: nil) + #expect(with.Image == without.Image) + #expect(with.Cmd == without.Cmd) + #expect(with.HostConfig?.Binds == without.HostConfig?.Binds) + #expect(with.HostConfig?.Memory == without.HostConfig?.Memory) + #expect(with.HostConfig?.NanoCpus == without.HostConfig?.NanoCpus) + #expect(with.HostConfig?.CpusetCpus == without.HostConfig?.CpusetCpus) + #expect(with.HostConfig?.Init == without.HostConfig?.Init) + } + } + + // MARK: - Failure Surfacing + + @Suite("DockerRuntime Runtime Failure Surfacing") + struct DockerRuntimeFailureSurfacingTests { + + private func selection(_ name: String?, _ kind: DockerRuntimeKind) -> DockerRuntimeSelection { + .init(name: name, kind: kind, source: .configured) + } + + @Test("A create failure under a hardened runtime is reported as a runtime failure") + func hardenedFailureIsSurfaced() throws { + let error = DockerRuntime.surfaceRuntimeFailure( + DockerRuntimeError.apiError(400, "Unknown runtime specified kata"), + runtime: selection("kata", .kata)) + + let described = try #require((error as? DockerRuntimeError)?.errorDescription) + #expect(described.contains("`kata`")) + #expect(described.contains("Unknown runtime specified kata")) + // The whole point: the user learns the container did not start, not that it quietly ran. + #expect(described.contains("was not started")) + } + + @Test("An unrecognized configured alias also surfaces as a runtime failure") + func customAliasFailureIsSurfaced() { + let error = DockerRuntime.surfaceRuntimeFailure( + DockerRuntimeError.apiError(400, "Unknown runtime"), + runtime: selection("my-shim", .unknown)) + guard case DockerRuntimeError.runtimeUnavailable(let runtime, _) = error else { + Issue.record("expected runtimeUnavailable, got \(error)") + return + } + #expect(runtime == "my-shim") + } + + /// A missing image is not a runtime problem; dressing it up as one sends the user off + /// to debug their Kata install. + @Test("A create failure the daemon blames on something else is passed through") + func unrelatedFailureIsUntouched() { + let error = DockerRuntime.surfaceRuntimeFailure( + DockerRuntimeError.apiError(404, "No such image: alpine:latest"), + runtime: selection("kata", .kata)) + guard case DockerRuntimeError.apiError(let code, _) = error else { + Issue.record("expected the original apiError, got \(error)") + return + } + #expect(code == 404) + } + + @Test("A plain runc failure is passed through untouched") + func standardFailureIsUntouched() { + let original = DockerRuntimeError.apiError(500, "no space left on device") + let error = DockerRuntime.surfaceRuntimeFailure( + original, runtime: selection("runc", .standard)) + guard case DockerRuntimeError.apiError(let code, _) = error else { + Issue.record("expected the original apiError, got \(error)") + return + } + #expect(code == 500) + } + + @Test("Non-API errors are passed through untouched") + func nonAPIErrorIsUntouched() { + let error = DockerRuntime.surfaceRuntimeFailure( + DockerRuntimeError.socketError("broken pipe"), runtime: selection("kata", .kata)) + guard case DockerRuntimeError.socketError = error else { + Issue.record("expected the original socketError, got \(error)") + return + } + } + } + + // MARK: - Integration + + @Suite("DockerRuntime Runtime Selection Integration", .enabled(if: isDockerAvailable())) + struct DockerRuntimeSelectionIntegrationTests { + + private func makeRuntime(ociRuntime: String? = nil) -> DockerRuntime { + DockerRuntime( + config: ContainerRuntimeConfiguration( + storagePath: "/tmp/claudec-test-docker-runtime-selection", + endpoint: ProcessInfo.processInfo.environment["CLAUDEC_DOCKER_ENDPOINT"], + ociRuntime: ociRuntime)) + } + + @Test("The daemon reports its registered runtimes") + func daemonReportsRuntimes() async throws { + let client = DockerAPIClient( + endpoint: ProcessInfo.processInfo.environment["CLAUDEC_DOCKER_ENDPOINT"] + ?? "/var/run/docker.sock") + defer { Task { try? await client.shutdown() } } + + let info = try await client.info() + let names = (info.Runtimes ?? [:]).keys.sorted() + print("DIAG registered runtimes: \(names), default=\(info.DefaultRuntime ?? "")") + #expect(!names.isEmpty) + } + + @Test("Selection picks a runtime the daemon actually offers") + func selectionMatchesDaemon() async throws { + let runtime = makeRuntime() + defer { Task { try? await runtime.shutdown() } } + try await runtime.prepare() + + let selection = await runtime.selectedRuntime() + print("DIAG selected runtime: \(selection.name ?? "") (\(selection.kind))") + #expect(selection.source != .configured) + } + + /// The no-silent-downgrade guarantee, checked against a real daemon: a runtime the + /// host cannot provide must fail loudly instead of quietly running under `runc`. + @Test("An unavailable configured runtime fails instead of falling back") + func unavailableRuntimeDoesNotFallBack() async throws { + let runtime = makeRuntime(ociRuntime: "agentc-nonexistent-runtime") + defer { Task { try? await runtime.shutdown() } } + try await runtime.prepare() + _ = try await runtime.pullImage(ref: "alpine:latest") + + await #expect(throws: DockerRuntimeError.self) { + let container = try await runtime.runContainer( + imageRef: "alpine:latest", + configuration: ContainerConfiguration(entrypoint: ["true"], io: .standardIO)) + // Should be unreachable; clean up if the daemon surprised us. + try? await runtime.removeContainer(container) + } + } + + @Test("An explicitly configured runc runs containers normally") + func explicitRuncRuns() async throws { + let runtime = makeRuntime(ociRuntime: "runc") + defer { Task { try? await runtime.shutdown() } } + try await runtime.prepare() + _ = try await runtime.pullImage(ref: "alpine:latest") + + let selection = await runtime.selectedRuntime() + #expect(selection.name == "runc") + #expect(selection.warning == nil) + + let stdout = MockWriter() + let container = try await runtime.runContainer( + imageRef: "alpine:latest", + configuration: ContainerConfiguration( + entrypoint: ["echo", "explicit-runc"], + io: .custom(stdin: EmptyReaderStream(), stdout: stdout, stderr: MockWriter()))) + let exitCode = try await container.wait(timeoutInSeconds: 30) + try await runtime.removeContainer(container) + + #expect(exitCode == 0) + #expect(stdout.string.contains("explicit-runc")) + } + } + + // MARK: - Hardened Runtime E2E + + /// The runtime alias CI installed on the Docker host, e.g. `runsc` or `kata`. + /// + /// Set by the `runtime-e2e` job in `.github/workflows/test.yml`. Unset everywhere else, + /// which skips this suite — installing Kata or gVisor is a host-level change no + /// developer machine should be assumed to have made. + func expectedHardenedRuntime() -> String? { + ProcessInfo.processInfo.environment["AGENTC_TEST_DOCKER_RUNTIME"] + } + + /// The host's kernel release, used to prove a container is *not* sharing it. + func hostKernelRelease() -> String? { + guard + let release = try? String(contentsOfFile: "/proc/sys/kernel/osrelease", encoding: .utf8) + else { return nil } + return release.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// End-to-end proof that discovery, selection, and `HostConfig.Runtime` combine into a + /// real isolation boundary — not just the right bytes on the wire. + @Suite( + "DockerRuntime Hardened Runtime E2E", + .enabled(if: isDockerAvailable() && expectedHardenedRuntime() != nil)) + struct DockerHardenedRuntimeE2ETests { + + private var expected: String { expectedHardenedRuntime() ?? "" } + + private func makeRuntime(ociRuntime: String? = nil) -> DockerRuntime { + DockerRuntime( + config: ContainerRuntimeConfiguration( + storagePath: "/tmp/claudec-test-docker-hardened", + endpoint: ProcessInfo.processInfo.environment["CLAUDEC_DOCKER_ENDPOINT"], + ociRuntime: ociRuntime)) + } + + /// Run a command and return its stdout. + private func output( + _ runtime: DockerRuntime, _ command: String, expectSuccess: Bool = true + ) async throws -> String { + let stdout = MockWriter() + let stderr = MockWriter() + let container = try await runtime.runContainer( + imageRef: "alpine:latest", + configuration: ContainerConfiguration( + entrypoint: ["/bin/sh", "-c", command], + io: .custom(stdin: EmptyReaderStream(), stdout: stdout, stderr: stderr))) + let exitCode = try await container.wait(timeoutInSeconds: 120) + try await runtime.removeContainer(container) + if expectSuccess { + #expect(exitCode == 0, "stderr: \(stderr.string)") + } + return stdout.string + } + + /// Discovery must find the hardened runtime on its own — CI registers it with the + /// daemon and tells us nothing else. + @Test("Discovery auto-selects the hardened runtime the host provides") + func autoSelectsHardenedRuntime() async throws { + let runtime = makeRuntime() + defer { Task { try? await runtime.shutdown() } } + try await runtime.prepare() + + let selection = await runtime.selectedRuntime() + print("DIAG hardened E2E: expected=\(expected) selected=\(selection.name ?? "")") + #expect(selection.name == expected) + #expect(selection.source == .discovered) + #expect(selection.kind == .kata || selection.kind == .gVisor) + // A host that offers real isolation must not be nagged about runc. + #expect(selection.warning == nil) + } + + @Test("Containers actually run under the hardened runtime") + func containersRunHardened() async throws { + let runtime = makeRuntime() + defer { Task { try? await runtime.shutdown() } } + try await runtime.prepare() + _ = try await runtime.pullImage(ref: "alpine:latest") + + let marker = try await output(runtime, "echo hardened-ok") + #expect(marker.contains("hardened-ok")) + + // Both Kata (real guest kernel) and gVisor (synthetic kernel) report a release that + // differs from the host's. Under runc they would be identical. + let containerRelease = try await output(runtime, "uname -r") + .trimmingCharacters(in: .whitespacesAndNewlines) + print("DIAG hardened E2E: host=\(hostKernelRelease() ?? "?") container=\(containerRelease)") + if let host = hostKernelRelease() { + #expect( + containerRelease != host, + "container shares the host kernel — \(expected) did not take effect") + } + } + + @Test("The runtime can also be selected explicitly by name") + func explicitHardenedRuntime() async throws { + let runtime = makeRuntime(ociRuntime: expected) + defer { Task { try? await runtime.shutdown() } } + try await runtime.prepare() + _ = try await runtime.pullImage(ref: "alpine:latest") + + let selection = await runtime.selectedRuntime() + #expect(selection.name == expected) + #expect(selection.source == .configured) + #expect(try await output(runtime, "echo explicit-ok").contains("explicit-ok")) + } + + /// A full session — mounts, bootstrap-less entrypoint, wait, cleanup — under the + /// hardened runtime, so we learn if anything in the adapter breaks outside runc. + @Test("An AgentSession runs end to end under the hardened runtime") + func agentSessionUnderHardenedRuntime() async throws { + let runtime = makeRuntime() + defer { Task { try? await runtime.shutdown() } } + try await runtime.prepare() + _ = try await runtime.pullImage(ref: "alpine:latest") + + let tmpBase = URL(fileURLWithPath: "/tmp/claudec-test-hardened-\(UUID().uuidString)") + let configsDir = tmpBase.appendingPathComponent("configurations") + try FileManager.default.createDirectory(at: configsDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpBase) } + + let session = AgentSession( + config: IsolationConfig( + image: "alpine:latest", + profileHomeDir: tmpBase.appendingPathComponent("home"), + workspace: tmpBase, + configurationsDir: configsDir, + configurations: [], + bootstrapMode: .imageDefault, + arguments: ["/bin/sh", "-c", "echo session-ok"], + customPTY: true + ), + runtime: runtime) + + let collector = Task { () -> Data in + var buffer = Data() + for await chunk in session.rawOut { buffer.append(contentsOf: chunk) } + return buffer + } + + try await session.start() + let exitCode = try await session.wait() + let text = String(decoding: await collector.value, as: UTF8.self) + print("DIAG hardened E2E session: exit=\(exitCode) output=\(text)") + + #expect(exitCode == 0) + #expect(text.contains("session-ok")) + } + } +#endif diff --git a/Tests/AgentIsolationTests/ProjectSettingsTests.swift b/Tests/AgentIsolationTests/ProjectSettingsTests.swift index 075cc6b..ced6bc2 100644 --- a/Tests/AgentIsolationTests/ProjectSettingsTests.swift +++ b/Tests/AgentIsolationTests/ProjectSettingsTests.swift @@ -53,12 +53,28 @@ struct ProjectSettingsDecodingTests { #expect(agent.respectImageEntrypoint == true) } + @Test("Decodes the docker runtime setting") + func decodesDockerRuntime() throws { + let json = """ + { + "agent": { "image": "custom:v1" }, + "docker": { "runtime": "kata" } + } + """ + let settings = try JSONDecoder().decode( + ProjectSettings.self, from: Data(json.utf8)) + + #expect(settings.agent?.image == "custom:v1") + #expect(settings.docker?.runtime == "kata") + } + @Test("Decodes empty object") func decodesEmpty() throws { let json = "{}" let settings = try JSONDecoder().decode( ProjectSettings.self, from: Data(json.utf8)) #expect(settings.agent == nil) + #expect(settings.docker == nil) } @Test("Decodes partial agent fields") diff --git a/docs/docker-runtimes.md b/docs/docker-runtimes.md new file mode 100644 index 0000000..6a32138 --- /dev/null +++ b/docs/docker-runtimes.md @@ -0,0 +1,50 @@ +# Safer Docker Isolation + +AI agents run code you did not write. Standard Docker containers use the host kernel, so a +container escape can expose the host. `agentc` reduces that risk by automatically choosing +the strongest runtime registered with Docker: + +1. **Kata Containers** — runs each container in a lightweight VM with its own kernel. +2. **gVisor (`runsc`)** — handles container system calls in a userspace application kernel. +3. **`runc`** — standard Docker isolation, which shares the host kernel. + +If only `runc` is available, `agentc` uses it and prints a warning. If Docker's runtimes +cannot be identified, it uses the daemon's default and warns. Unrecognized runtime names are +included so you can select a hardened custom runtime yourself. + +## Choose a runtime + +Override automatic selection on the command line: + +```sh +agentc run --docker-runtime kata +agentc run --docker-runtime runsc +agentc run --docker-runtime runc +``` + +Or set it for a project in `.agentc/settings.json`: + +```json +{ + "docker": { + "runtime": "runsc" + } +} +``` + +The name is passed directly to Docker, so custom aliases and containerd shim names work too. +Choosing a runtime explicitly suppresses the warning; use `runc` only when sharing the host +kernel is acceptable for your environment. + +## No silent fallback + +If Docker rejects the selected runtime, `agentc` stops and reports the error. It never retries +with a weaker runtime, so a broken Kata or gVisor installation cannot silently remove the +isolation boundary you expected. + +To add a hardened runtime, see the installation guides for +[Kata Containers](https://github.com/kata-containers/kata-containers/blob/main/docs/installation.md) +or [gVisor](https://gvisor.dev/docs/user_guide/quick_start/docker/). + +This setting applies only to the Docker backend. The Apple Container backend already runs +each container in its own VM. diff --git a/docs/project-settings.md b/docs/project-settings.md index a6b097f..1482060 100644 --- a/docs/project-settings.md +++ b/docs/project-settings.md @@ -53,6 +53,9 @@ All fields are optional. Only the values you specify take effect. "memoryMiB": "", "bootstrap": "", "respectImageEntrypoint": "" + }, + "docker": { + "runtime": "" } } ``` @@ -73,6 +76,7 @@ All fields are optional. Only the values you specify take effect. | `agent.memoryMiB` | `--memory-mib` | Container memory limit in MiB. | | `agent.bootstrap` | `--bootstrap` | Path to a custom bootstrap/entrypoint script. | | `agent.respectImageEntrypoint` | `--respect-image-entrypoint` | Use the image's built-in entrypoint. | +| `docker.runtime` | `--docker-runtime` | Docker runtime to run containers with (e.g. `kata`, `runsc`, `runc`). Defaults to the strongest isolation the daemon offers — see [Docker Runtime Selection](docker-runtimes.md). | ### Override and Merge Rules @@ -80,7 +84,7 @@ When both CLI flags and project settings specify a value, the behavior depends o **Override** (CLI wins, project settings used as fallback): -- `image`, `profile`, `configurations`, `cpus`, `memoryMiB`, `bootstrap`, `respectImageEntrypoint` +- `image`, `profile`, `configurations`, `cpus`, `memoryMiB`, `bootstrap`, `respectImageEntrypoint`, `docker.runtime` **Merge** (both sets are combined): From b3a0d6a85df335cf433da3d6025325f5df9856b0 Mon Sep 17 00:00:00 2001 From: laosb Date: Sat, 8 Aug 2026 17:14:25 +0800 Subject: [PATCH 2/2] doc: update README to touch upon the topic of safer Docker runtimes. --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3312a81..0d32fc5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [GitHub **macOS / Linux (Docker runtime):** x64 or arm64, Docker Engine API v1.44+ (Docker, Podman with Docker compatibility, etc.). +> [!IMPORTANT] +> Standard Docker containers share the host kernel. Because agents run untrusted code, +> `agentc` automatically prefers Kata Containers or gVisor when available and warns when +> only standard `runc` isolation is available. See [*Safer Docker Isolation*](./docs/docker-runtimes.md). + ### Install ```sh @@ -78,12 +83,12 @@ To skip the bootstrap and use the image's own entrypoint: agentc run --respect-image-entrypoint -i my-image:latest ``` -### Container Isolation (Docker runtime) +### Docker Isolation Agents run code you did not write, so on the Docker backend `agentc` asks the daemon which runtimes it has and prefers the strongest isolation available: **Kata Containers** (a VM per container) over **gVisor** (`runsc`) over **`runc`** (shares the host kernel). If only `runc` -is available, `agentc` uses it and prints a one-time warning with setup instructions. +is available, `agentc` uses it and prints a warning with setup instructions. Pick one yourself — including `runc`, which also silences the warning: @@ -92,8 +97,8 @@ agentc run --docker-runtime runsc agentc run --docker-runtime runc # "I know, runc is fine here" ``` -See [docs/docker-runtimes.md](./docs/docker-runtimes.md). The Apple Container runtime -already gives every container its own VM, so this does not apply on macOS. +See [Safer Docker Isolation](./docs/docker-runtimes.md). The Apple Container backend already +gives every container its own VM, so runtime selection applies only to the Docker backend. ## Architecture