Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/AgentIsolation/IsolationConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>()
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[..<separator])

if let value = pending.removeValue(forKey: name) {
overridden.insert(name)
result.append("\(name)=\(value)")
} else if !overridden.contains(name) {
result.append(entry)
}
// else: a later duplicate of a name we already overrode — drop it.
}

for name in pending.keys.sorted() {
result.append("\(name)=\(pending[name]!)")
}

return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,11 @@
containerConfig.process.workingDirectory = workDir
}

// Environment
for (key, value) in configuration.environment {
containerConfig.process.environmentVariables.append("\(key)=\(value)")
}
// Environment: image defaults, with our values overriding matching names.
containerConfig.process.environmentVariables = AppleContainerEnvironment.merged(
imageDefaults: containerConfig.process.environmentVariables,
overrides: configuration.environment
)

// Mounts
for mount in configuration.mounts {
Expand Down
53 changes: 37 additions & 16 deletions Sources/AgentIsolationDockerRuntime/DockerRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,41 +130,52 @@ public final class DockerRuntime: ContainerRuntime, Sendable {
try await client.removeImage(nameOrDigest: digest)
}

public func runContainer(
imageRef: String,
configuration: ContainerConfiguration
) async throws -> 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 {
let opts = mount.isReadOnly ? "ro" : "rw"
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,
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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[..<separator])
// First occurrence wins, mirroring `getenv` on a duplicated `environ`.
if result[name] == nil {
result[name] = String(entry[entry.index(after: separator)...])
}
}
return result
}

// MARK: - Pass-through

@Test("Image defaults are unchanged when there are no overrides")
func noOverridesKeepsImageDefaults() {
let defaults = ["PATH=/usr/bin", "LANG=C.UTF-8"]
let merged = AppleContainerEnvironment.merged(imageDefaults: defaults, overrides: [:])
#expect(merged == defaults)
}

@Test("Overrides alone become the whole environment")
func overridesWithoutImageDefaults() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: [],
overrides: ["TZ": "America/Los_Angeles", "LC_ALL": "en_US.UTF-8"]
)
#expect(merged == ["LC_ALL=en_US.UTF-8", "TZ=America/Los_Angeles"])
}

@Test("Empty inputs produce an empty environment")
func emptyInputs() {
#expect(AppleContainerEnvironment.merged(imageDefaults: [], overrides: [:]).isEmpty)
}

// MARK: - Override semantics

@Test("Override replaces the image value in place instead of appending")
func overrideReplacesInPlace() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["PATH=/usr/bin", "TZ=UTC", "LANG=C.UTF-8"],
overrides: ["TZ": "America/Los_Angeles"]
)
// Same slot, same count — an appended duplicate would leave "TZ=UTC" first
// and `getenv` inside the container would still report UTC.
#expect(merged == ["PATH=/usr/bin", "TZ=America/Los_Angeles", "LANG=C.UTF-8"])
#expect(!merged.contains("TZ=UTC"))
}

@Test("Overridden name appears exactly once")
func overriddenNameAppearsOnce() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["TZ=UTC"],
overrides: ["TZ": "Europe/Berlin"]
)
#expect(merged.filter { $0.hasPrefix("TZ=") }.count == 1)
#expect(parsed(merged)["TZ"] == "Europe/Berlin")
}

@Test("Overriding one name leaves the other image defaults alone")
func overridePreservesUnrelatedDefaults() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["PATH=/usr/bin", "HOME=/home/agent", "TZ=UTC"],
overrides: ["TZ": "Asia/Shanghai"]
)
let env = parsed(merged)
#expect(env["PATH"] == "/usr/bin")
#expect(env["HOME"] == "/home/agent")
#expect(env["TZ"] == "Asia/Shanghai")
#expect(merged.count == 3)
}

@Test("Duplicate image entries for an overridden name collapse to the override")
func duplicateImageEntriesCollapse() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["TZ=UTC", "PATH=/usr/bin", "TZ=Etc/GMT"],
overrides: ["TZ": "Europe/Berlin"]
)
#expect(merged == ["TZ=Europe/Berlin", "PATH=/usr/bin"])
}

@Test("Duplicate image entries are kept when not overridden")
func duplicateImageEntriesKeptWithoutOverride() {
let defaults = ["TZ=UTC", "TZ=Etc/GMT"]
let merged = AppleContainerEnvironment.merged(imageDefaults: defaults, overrides: [:])
#expect(merged == defaults)
}

// MARK: - New names

@Test("New names are appended after the image defaults")
func newNamesAppended() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["PATH=/usr/bin"],
overrides: ["TZ": "UTC"]
)
#expect(merged == ["PATH=/usr/bin", "TZ=UTC"])
}

@Test("Appended names are sorted, so the result is deterministic")
func appendedNamesAreSorted() {
let overrides = [
"TZ": "UTC", "LC_ALL": "en_US.UTF-8", "AGENTC_CONFIGURATIONS": "claude",
"EDITOR": "vim", "LANG": "en_US.UTF-8",
]
let expected = [
"PATH=/usr/bin",
"AGENTC_CONFIGURATIONS=claude",
"EDITOR=vim",
"LANG=en_US.UTF-8",
"LC_ALL=en_US.UTF-8",
"TZ=UTC",
]
// Repeat: dictionary iteration order varies per instance, so a single pass could
// pass by luck if the output depended on it.
for _ in 0..<10 {
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["PATH=/usr/bin"], overrides: overrides)
#expect(merged == expected)
}
}

@Test("Every override reaches the container exactly once")
func allOverridesPresent() {
let overrides = ["TZ": "UTC", "LANG": "C.UTF-8", "FOO": "bar", "PATH": "/opt/bin"]
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["PATH=/usr/bin", "HOME=/home/agent"],
overrides: overrides
)
#expect(merged.count == 5) // 4 overrides + HOME
for (name, value) in overrides {
#expect(merged.filter { $0.hasPrefix("\(name)=") } == ["\(name)=\(value)"])
}
}

// MARK: - Value shapes

@Test("Empty override value is preserved as a set-but-empty variable")
func emptyValue() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["EMPTY=not-empty"],
overrides: ["EMPTY": "", "ALSO_EMPTY": ""]
)
#expect(merged == ["EMPTY=", "ALSO_EMPTY="])
#expect(parsed(merged)["EMPTY"] == "")
}

@Test("Values containing '=' are preserved intact")
func valueWithEquals() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: [],
overrides: ["JAVA_TOOL_OPTIONS": "-Dfoo=bar -Dbaz=qux"]
)
#expect(merged == ["JAVA_TOOL_OPTIONS=-Dfoo=bar -Dbaz=qux"])
}

@Test("An image entry whose value contains '=' is matched on its name only")
func imageValueWithEqualsIsOverridable() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["OPTS=-Dfoo=bar"],
overrides: ["OPTS": "-Dfoo=baz"]
)
#expect(merged == ["OPTS=-Dfoo=baz"])
}

@Test("Values with spaces, newlines, and non-ASCII text survive unmodified")
func exoticValues() {
let overrides = [
"SPACED": "a b c",
"MULTILINE": "line1\nline2",
"UNICODE": "日本語 🌏",
]
let env = parsed(AppleContainerEnvironment.merged(imageDefaults: [], overrides: overrides))
#expect(env["SPACED"] == "a b c")
#expect(env["MULTILINE"] == "line1\nline2")
#expect(env["UNICODE"] == "日本語 🌏")
}

// MARK: - Malformed image entries

@Test("Image entries without '=' are passed through untouched")
func imageEntryWithoutSeparator() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["MALFORMED", "TZ=UTC"],
overrides: ["TZ": "Europe/Berlin"]
)
#expect(merged == ["MALFORMED", "TZ=Europe/Berlin"])
}

@Test("An image entry with an empty name is not confused with an override")
func imageEntryWithEmptyName() {
let merged = AppleContainerEnvironment.merged(
imageDefaults: ["=stray", "TZ=UTC"],
overrides: ["TZ": "Europe/Berlin"]
)
#expect(merged == ["=stray", "TZ=Europe/Berlin"])
}
}
#endif
Loading
Loading