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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ curl -fsSL https://raw.githubusercontent.com/laosb/agentc/main/install.sh | sh
agentc run # start default agent (claude) in $PWD
agentc run -c claude,copilot # activate multiple configurations
agentc run "explain this code" # forward args to the agent entrypoint
agentc run -e TZ=Europe/Berlin # set a container environment variable
agentc sh # open a shell in the container
agentc sh -- ls -la /home/agent # run a command inside the container
agentc version # print version info
Expand Down
4 changes: 2 additions & 2 deletions Sources/AgentIsolation/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,8 @@ public final class AgentSession<Runtime: ContainerRuntime>: Sendable {
break
}

// Environment: pass configurations and optional entrypoint override to bootstrap
var environment: [String: String] = [:]
// Environment: start with user values, excluding reserved bootstrap controls.
var environment = config.environment.filter { !$0.key.hasPrefix("AGENTC_") }
environment["AGENTC_CONFIGURATIONS"] = config.configurations.joined(separator: ",")
if config.verbose {
environment["AGENTC_VERBOSE"] = "1"
Expand Down
6 changes: 6 additions & 0 deletions Sources/AgentIsolation/IsolationConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ public struct IsolationConfig: Sendable {
/// Arguments forwarded to the container entrypoint.
public var arguments: [String]

/// User-defined environment variables passed to the container.
/// Internal `AGENTC_*` variables take precedence when the session starts.
public var environment: [String: String]

/// Whether to allocate a pseudo-TTY. Typically true when stdin is a terminal.
public var allocateTTY: Bool

Expand Down Expand Up @@ -80,6 +84,7 @@ public struct IsolationConfig: Sendable {
configurations: [String] = ["claude"],
bootstrapMode: BootstrapMode = .imageDefault,
arguments: [String] = [],
environment: [String: String] = [:],
allocateTTY: Bool = false,
cpuCount: Int = 1,
memoryLimitMiB: Int = 1536,
Expand All @@ -95,6 +100,7 @@ public struct IsolationConfig: Sendable {
self.configurations = configurations
self.bootstrapMode = bootstrapMode
self.arguments = arguments
self.environment = environment
self.allocateTTY = allocateTTY
self.cpuCount = cpuCount
self.memoryLimitMiB = memoryLimitMiB
Expand Down
3 changes: 3 additions & 0 deletions Sources/AgentIsolation/ProjectSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public struct ProjectSettings: Codable, Sendable, Equatable {
public var excludes: [String]?
public var configurations: [String]?
public var additionalMounts: [String]?
public var environment: [String: String]?
public var defaultArguments: [String]?
public var additionalArguments: [String]?
public var cpus: Int?
Expand All @@ -35,6 +36,7 @@ public struct ProjectSettings: Codable, Sendable, Equatable {
excludes: [String]? = nil,
configurations: [String]? = nil,
additionalMounts: [String]? = nil,
environment: [String: String]? = nil,
defaultArguments: [String]? = nil,
additionalArguments: [String]? = nil,
cpus: Int? = nil,
Expand All @@ -47,6 +49,7 @@ public struct ProjectSettings: Codable, Sendable, Equatable {
self.excludes = excludes
self.configurations = configurations
self.additionalMounts = additionalMounts
self.environment = environment
self.defaultArguments = defaultArguments
self.additionalArguments = additionalArguments
self.cpus = cpus
Expand Down
5 changes: 5 additions & 0 deletions Sources/agentc/Commands/InitCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ struct InitCommand: AsyncParsableCommand {
excludes: excludes,
configurations: configurations,
additionalMounts: options.additionalMount.isEmpty ? nil : options.additionalMount,
environment: options.env.isEmpty
? nil
: Dictionary(
options.env.map { ($0.key, $0.value) },
uniquingKeysWith: { _, latest in latest }),
cpus: options.cpuCount ?? 1,
memoryMiB: options.memoryLimitMiB ?? 1536,
bootstrap: options.bootstrapScript,
Expand Down
1 change: 1 addition & 0 deletions Sources/agentc/SessionRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ enum SessionRunner {
configurations: configNames,
bootstrapMode: bootstrapMode,
arguments: arguments,
environment: options.resolveEnvironment(projectSettings: projectSettings),
allocateTTY: allocateTTY,
cpuCount: options.resolveCpuCount(projectSettings: projectSettings),
memoryLimitMiB: options.resolveMemoryLimitMiB(projectSettings: projectSettings),
Expand Down
38 changes: 38 additions & 0 deletions Sources/agentc/SharedOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ import ArgumentParser
import Foundation
#endif

struct EnvironmentVariableOption: ExpressibleByArgument, Sendable, Equatable {
let key: String
let value: String

init?(argument: String) {
guard let separator = argument.firstIndex(of: "="), separator != argument.startIndex else {
return nil
}

let key = String(argument[..<separator])
let value = String(argument[argument.index(after: separator)...])
guard !key.contains("\0"), !value.contains("\0") else {
return nil
}

self.key = key
self.value = value
}
}

struct SharedOptions: ParsableArguments {
@Option(name: .shortAndLong, help: "Container runtime.")
var runtime: RuntimeChoice?
Expand Down Expand Up @@ -55,6 +75,14 @@ struct SharedOptions: ParsableArguments {
)
var additionalMount: [String] = []

@Option(
name: .shortAndLong,
help: ArgumentHelp(
"Set a container environment variable. Repeat to set multiple variables.",
valueName: "KEY=VALUE")
)
var env: [EnvironmentVariableOption] = []

@Option(
name: [.short, .customLong("configurations")],
help: "Comma-separated agent configuration names."
Expand Down Expand Up @@ -226,6 +254,16 @@ extension SharedOptions {
return result
}

/// Resolve container environment variables.
/// Project settings provide defaults; CLI values override matching keys.
func resolveEnvironment(projectSettings: ProjectSettings? = nil) -> [String: String] {
var result = projectSettings?.agent?.environment ?? [:]
for variable in env {
result[variable.key] = variable.value
}
return result
}

/// Resolve entrypoint arguments.
///
/// - `defaultArguments`: used when no CLI rest arguments are given; CLI overrides.
Expand Down
30 changes: 30 additions & 0 deletions Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,36 @@

try await runtime.removeContainer(container)
}

@Test("runContainer passes environment variables")
func runContainerEnvironment() async throws {
let runtime = makeRuntime()
defer { Task { try? await runtime.shutdown() } }
try await runtime.prepare()

_ = try await runtime.pullImage(ref: "alpine:latest")

let stdout = MockWriter()
let containerConfig = ContainerConfiguration(
entrypoint: ["/bin/sh", "-c", "printf '%s|%s' \"$TZ\" \"$LC_ALL\""],
environment: [
"TZ": "America/Los_Angeles",
"LC_ALL": "en_US.UTF-8",
],
io: .custom(stdin: EmptyReaderStream(), stdout: stdout, stderr: MockWriter())
)

let container = try await runtime.runContainer(
imageRef: "alpine:latest",
configuration: containerConfig
)

let exitCode = try await container.wait(timeoutInSeconds: 30)
try await runtime.removeContainer(container)

#expect(exitCode == 0)
#expect(stdout.string == "America/Los_Angeles|en_US.UTF-8")
}
}

// MARK: - Custom IO Integration Tests
Expand Down
38 changes: 38 additions & 0 deletions Tests/AgentIsolationTests/AgentSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,44 @@ struct ConfigurationTests {
#expect(env["AGENTC_CONFIGURATIONS"] == "claude,swift")
}

@Test("Passes custom environment variables and reserves AGENTC names")
func passesCustomEnvironment() async throws {
let runtime = MockRuntime(config: .init(storagePath: "/tmp"))
let base = URL(fileURLWithPath: "/tmp/agentc-test-env-\(UUID().uuidString)")
let profileDir = base.appendingPathComponent("home")
let configsDir = try makeConfigsDir(configs: [:])
defer {
try? FileManager.default.removeItem(at: base)
try? FileManager.default.removeItem(at: configsDir)
}

let config = IsolationConfig(
image: "test:latest",
profileHomeDir: profileDir,
workspace: URL(fileURLWithPath: "/tmp"),
configurationsDir: configsDir,
configurations: ["claude"],
arguments: ["echo"],
environment: [
"TZ": "America/Los_Angeles",
"LC_ALL": "en_US.UTF-8",
"EMPTY": "",
"AGENTC_CONFIGURATIONS": "overridden",
"AGENTC_ENTRYPOINT_OVERRIDE": "1",
]
)
let session = AgentSession(config: config, runtime: runtime)
try await session.start()
_ = try await session.wait()

let environment = runtime.lastContainerConfiguration!.environment
#expect(environment["TZ"] == "America/Los_Angeles")
#expect(environment["LC_ALL"] == "en_US.UTF-8")
#expect(environment["EMPTY"] == "")
#expect(environment["AGENTC_CONFIGURATIONS"] == "claude")
#expect(environment["AGENTC_ENTRYPOINT_OVERRIDE"] == nil)
}

@Test("Creates additional mounts from single configuration")
func additionalMountsSingle() async throws {
let runtime = MockRuntime(config: .init(storagePath: "/tmp"))
Expand Down
11 changes: 11 additions & 0 deletions Tests/AgentIsolationTests/ProjectSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ struct ProjectSettingsDecodingTests {
"excludes": [".git", "node_modules"],
"configurations": ["claude", "copilot"],
"additionalMounts": ["/data/models"],
"environment": {
"TZ": "America/Los_Angeles",
"LC_ALL": "en_US.UTF-8"
},
"defaultArguments": ["--model", "opus"],
"additionalArguments": ["--verbose"],
"cpus": 4,
Expand All @@ -35,6 +39,12 @@ struct ProjectSettingsDecodingTests {
#expect(agent.excludes == [".git", "node_modules"])
#expect(agent.configurations == ["claude", "copilot"])
#expect(agent.additionalMounts == ["/data/models"])
#expect(
agent.environment
== [
"TZ": "America/Los_Angeles",
"LC_ALL": "en_US.UTF-8",
])
#expect(agent.defaultArguments == ["--model", "opus"])
#expect(agent.additionalArguments == ["--verbose"])
#expect(agent.cpus == 4)
Expand Down Expand Up @@ -71,6 +81,7 @@ struct ProjectSettingsDecodingTests {
#expect(agent.excludes == nil)
#expect(agent.configurations == nil)
#expect(agent.additionalMounts == nil)
#expect(agent.environment == nil)
#expect(agent.defaultArguments == nil)
#expect(agent.additionalArguments == nil)
#expect(agent.memoryMiB == nil)
Expand Down
27 changes: 27 additions & 0 deletions Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ struct InitCommandIntegrationTests {
"--image", "custom:latest",
"--configurations", "claude,copilot",
"--exclude", "node_modules,.git",
"--env", "TZ=America/Los_Angeles",
"-e", "LC_ALL=en_US.UTF-8",
"-e", "EMPTY=",
]
)
#expect(result.exitCode == 0)
Expand All @@ -66,6 +69,30 @@ struct InitCommandIntegrationTests {
#expect(agent["memoryMiB"] as? Int == 4096)
#expect(agent["configurations"] as? [String] == ["claude", "copilot"])
#expect(agent["excludes"] as? [String] == ["node_modules", ".git"])
let environment = agent["environment"] as? [String: String]
#expect(environment?["TZ"] == "America/Los_Angeles")
#expect(environment?["LC_ALL"] == "en_US.UTF-8")
#expect(environment?["EMPTY"] == "")
}

@Test("agentc init rejects malformed environment options")
func initRejectsMalformedEnvironment() async throws {
let base = URL(
fileURLWithPath: "/tmp/__TEST_agentc_init_env.\(UUID().uuidString.prefix(6))")
try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: base) }

let result = await runAgentc(
args: [
"init",
base.path,
"--skip-container-init",
"--env", "MISSING_SEPARATOR",
]
)

#expect(result.exitCode != 0)
#expect(!FileManager.default.fileExists(atPath: base.appendingPathComponent(".agentc").path))
}

@Test("agentc init with --profile writes profile to settings")
Expand Down
69 changes: 69 additions & 0 deletions Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,40 @@ struct ProjectSettingsIntegrationTests {
#expect(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}

@Test("--agentc-folder applies agent.environment setting")
func agentcFolderAppliesEnvironment() async throws {
let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_ps_env.\(UUID().uuidString.prefix(6))")
defer { try? FileManager.default.removeItem(at: base) }

let settingsDir = base.appendingPathComponent("settings")
try writeProjectSettings(
"""
{
"agent": {
"environment": {
"TZ": "America/Los_Angeles",
"LC_ALL": "en_US.UTF-8"
}
}
}
""",
at: settingsDir)

let result = await runAgentc(
args: [
"sh",
"--profile", sharedProfile,
"--configurations-dir", sharedConfigurationsDir,
"--no-update-image",
"--agentc-folder", settingsDir.appendingPathComponent(".agentc").path,
"--", "printf '%s|%s' \"$TZ\" \"$LC_ALL\"",
]
)

#expect(result.exitCode == 0)
#expect(result.stdout == "America/Los_Angeles|en_US.UTF-8")
}

// MARK: - CLI Override

@Test("CLI --cpus overrides project settings agent.cpus")
Expand Down Expand Up @@ -145,6 +179,41 @@ struct ProjectSettingsIntegrationTests {
#expect(reported == "3")
}

@Test("CLI --env overrides matching project environment variables")
func cliOverridesProjectEnvironment() async throws {
let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_ps_envov.\(UUID().uuidString.prefix(6))")
defer { try? FileManager.default.removeItem(at: base) }

let settingsDir = base.appendingPathComponent("settings")
try writeProjectSettings(
"""
{
"agent": {
"environment": {
"TZ": "Asia/Shanghai",
"LANG": "en_US.UTF-8"
}
}
}
""",
at: settingsDir)

let result = await runAgentc(
args: [
"sh",
"--profile", sharedProfile,
"--configurations-dir", sharedConfigurationsDir,
"--no-update-image",
"--agentc-folder", settingsDir.appendingPathComponent(".agentc").path,
"--env", "TZ=Europe/Berlin",
"--", "printf '%s|%s' \"$TZ\" \"$LANG\"",
]
)

#expect(result.exitCode == 0)
#expect(result.stdout == "Europe/Berlin|en_US.UTF-8")
}

// MARK: - Merge Behavior

@Test("CLI --exclude and project excludes are both applied")
Expand Down
Loading
Loading