From e4d60fcc986a805d5f445072fe9a440c98e0f0f1 Mon Sep 17 00:00:00 2001 From: laosb Date: Fri, 31 Jul 2026 14:26:00 +0800 Subject: [PATCH] feat: custom env support for apple container runtime. --- Sources/AgentIsolation/IsolationConfig.swift | 3 +- .../AppleContainerEnvironment.swift | 45 ++++ .../AppleContainerRuntime.swift | 9 +- .../DockerRuntime.swift | 53 ++-- .../AppleContainerEnvironmentTests.swift | 210 ++++++++++++++++ .../DockerEnvironmentTests.swift | 237 ++++++++++++++++++ .../DockerRuntimeTests.swift | 3 +- 7 files changed, 538 insertions(+), 22 deletions(-) create mode 100644 Sources/AgentIsolationAppleContainerRuntime/AppleContainerEnvironment.swift create mode 100644 Tests/AgentIsolationAppleContainerRuntimeTests/AppleContainerEnvironmentTests.swift create mode 100644 Tests/AgentIsolationDockerRuntimeTests/DockerEnvironmentTests.swift diff --git a/Sources/AgentIsolation/IsolationConfig.swift b/Sources/AgentIsolation/IsolationConfig.swift index 2fda813..76b83f5 100644 --- a/Sources/AgentIsolation/IsolationConfig.swift +++ b/Sources/AgentIsolation/IsolationConfig.swift @@ -45,7 +45,8 @@ public struct IsolationConfig: Sendable { public var arguments: [String] /// User-defined environment variables passed to the container. - /// Internal `AGENTC_*` variables take precedence when the session starts. + /// The `AGENTC_*` namespace is reserved for internal bootstrap controls; names in it + /// are dropped when the session starts. public var environment: [String: String] /// Whether to allocate a pseudo-TTY. Typically true when stdin is a terminal. diff --git a/Sources/AgentIsolationAppleContainerRuntime/AppleContainerEnvironment.swift b/Sources/AgentIsolationAppleContainerRuntime/AppleContainerEnvironment.swift new file mode 100644 index 0000000..78c45eb --- /dev/null +++ b/Sources/AgentIsolationAppleContainerRuntime/AppleContainerEnvironment.swift @@ -0,0 +1,45 @@ +/// Environment variable handling for ``AppleContainerRuntime``. +/// +/// Deliberately free of `Containerization` types so the merge rules build — and stay +/// unit-testable — on every platform, not just where the runtime itself is available. +enum AppleContainerEnvironment { + /// Merge agentc-supplied variables into an image's `KEY=VALUE` environment list. + /// + /// Entries are matched by name: an override replaces the image's value in place rather + /// than being appended, so an image default can never shadow a user value (a duplicate + /// name earlier in `environ` wins for `getenv`). This matches Docker's behavior, where + /// container `Env` overrides the image's `ENV` for the same name. + /// + /// Names not present in the image list are appended in sorted order, so the result is + /// deterministic even though `overrides` is an unordered dictionary. Image entries + /// without a `=` are passed through untouched. + static func merged(imageDefaults: [String], overrides: [String: String]) -> [String] { + var pending = overrides + var overridden = Set() + var result: [String] = [] + result.reserveCapacity(imageDefaults.count + overrides.count) + + for entry in imageDefaults { + guard let separator = entry.firstIndex(of: "=") else { + // Not a `KEY=VALUE` entry — nothing to match on, keep it as-is. + result.append(entry) + continue + } + let name = String(entry[.. DockerContainer { - let useTTY: Bool - switch configuration.io { - case .currentTerminal: useTTY = true - case .custom(_, _, _, let isTerminal): useTTY = isTerminal - default: useTTY = false + /// Whether the container should be created with a pseudo-TTY. + static func usesTTY(for io: ContainerConfiguration.IO) -> Bool { + switch io { + case .currentTerminal: return true + case .custom(_, _, _, let isTerminal): return isTerminal + default: return false } + } + /// Build the Docker Engine create-container payload for a configuration. + /// + /// Pure translation of ``ContainerConfiguration`` to Docker's wire format, kept separate + /// from ``runContainer(imageRef:configuration:)`` so it can be tested without a daemon. + /// + /// `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. + static func makeCreateRequest( + imageRef: String, + configuration: ContainerConfiguration + ) -> DockerCreateContainerRequest { // Build bind mounts var binds: [String] = [] for mount in configuration.mounts { @@ -148,23 +158,24 @@ public final class DockerRuntime: ContainerRuntime, Sendable { binds.append("\(mount.hostPath):\(mount.containerPath):\(opts)") } - // Build environment + // Build environment. Sorted so the same configuration always produces the same + // payload — `environment` is an unordered dictionary. let envVars: [String]? = configuration.environment.isEmpty ? nil - : configuration.environment.map { "\($0.key)=\($0.value)" } + : configuration.environment.keys.sorted().map { "\($0)=\(configuration.environment[$0]!)" } - // Create container – when a custom entrypoint override is requested, set Docker's - // Entrypoint field to replace the image's built-in ENTRYPOINT. Otherwise, use Cmd - // so the image's ENTRYPOINT receives these as arguments. + // When a custom entrypoint override is requested, set Docker's Entrypoint field to + // replace the image's built-in ENTRYPOINT. Otherwise, use Cmd so the image's + // ENTRYPOINT receives these as arguments. let entryArgs = configuration.entrypoint.isEmpty ? nil : configuration.entrypoint - let createConfig = DockerCreateContainerRequest( + return DockerCreateContainerRequest( Image: imageRef, Entrypoint: configuration.overridesImageEntrypoint ? entryArgs : nil, Cmd: configuration.overridesImageEntrypoint ? nil : entryArgs, Env: envVars, WorkingDir: configuration.workingDirectory, - Tty: useTTY, + Tty: Self.usesTTY(for: configuration.io), OpenStdin: true, AttachStdin: true, AttachStdout: true, @@ -177,6 +188,16 @@ public final class DockerRuntime: ContainerRuntime, Sendable { Init: true ) ) + } + + public func runContainer( + imageRef: String, + configuration: ContainerConfiguration + ) async throws -> DockerContainer { + let useTTY = Self.usesTTY(for: configuration.io) + + let createConfig = Self.makeCreateRequest( + imageRef: imageRef, configuration: configuration) let containerId = try await client.createContainer(config: createConfig) diff --git a/Tests/AgentIsolationAppleContainerRuntimeTests/AppleContainerEnvironmentTests.swift b/Tests/AgentIsolationAppleContainerRuntimeTests/AppleContainerEnvironmentTests.swift new file mode 100644 index 0000000..94b6758 --- /dev/null +++ b/Tests/AgentIsolationAppleContainerRuntimeTests/AppleContainerEnvironmentTests.swift @@ -0,0 +1,210 @@ +#if ContainerRuntimeAppleContainer + @testable import AgentIsolationAppleContainerRuntime + import Testing + + // MARK: - Environment Merge Unit Tests + + @Suite("AppleContainerRuntime Environment Merge") + struct AppleContainerEnvironmentTests { + + /// Parse a merged entry list back into names → values for order-independent checks. + private func parsed(_ entries: [String]) -> [String: String] { + var result: [String: String] = [:] + for entry in entries { + guard let separator = entry.firstIndex(of: "=") else { continue } + let name = String(entry[.. DockerCreateContainerRequest { + DockerRuntime.makeCreateRequest( + imageRef: "alpine:latest", + configuration: ContainerConfiguration( + entrypoint: entrypoint, + environment: environment, + io: .standardIO + ) + ) + } + + /// Parse `Env` back into names → values for order-independent checks. + private func parsed(_ entries: [String]) -> [String: String] { + var result: [String: String] = [:] + for entry in entries { + guard let separator = entry.firstIndex(of: "=") else { continue } + if result[String(entry[.. Bool { + /// Shared by the integration suites across this target's test files. + func isDockerAvailable() -> Bool { FileManager.default.fileExists(atPath: "/var/run/docker.sock") || ProcessInfo.processInfo.environment["CLAUDEC_DOCKER_ENDPOINT"] != nil }