diff --git a/README.md b/README.md index 4c42891..a17b7f1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/AgentIsolation/AgentSession.swift b/Sources/AgentIsolation/AgentSession.swift index e18a3d7..23b2b97 100644 --- a/Sources/AgentIsolation/AgentSession.swift +++ b/Sources/AgentIsolation/AgentSession.swift @@ -218,8 +218,8 @@ public final class AgentSession: 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" diff --git a/Sources/AgentIsolation/IsolationConfig.swift b/Sources/AgentIsolation/IsolationConfig.swift index f1862fa..2fda813 100644 --- a/Sources/AgentIsolation/IsolationConfig.swift +++ b/Sources/AgentIsolation/IsolationConfig.swift @@ -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 @@ -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, @@ -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 diff --git a/Sources/AgentIsolation/ProjectSettings.swift b/Sources/AgentIsolation/ProjectSettings.swift index 08cf021..ce1dbc4 100644 --- a/Sources/AgentIsolation/ProjectSettings.swift +++ b/Sources/AgentIsolation/ProjectSettings.swift @@ -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? @@ -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, @@ -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 diff --git a/Sources/agentc/Commands/InitCommand.swift b/Sources/agentc/Commands/InitCommand.swift index 64d6704..0c78343 100644 --- a/Sources/agentc/Commands/InitCommand.swift +++ b/Sources/agentc/Commands/InitCommand.swift @@ -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, diff --git a/Sources/agentc/SessionRunner.swift b/Sources/agentc/SessionRunner.swift index ebf088f..76b2fbb 100644 --- a/Sources/agentc/SessionRunner.swift +++ b/Sources/agentc/SessionRunner.swift @@ -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), diff --git a/Sources/agentc/SharedOptions.swift b/Sources/agentc/SharedOptions.swift index 4c9e5e7..779d291 100644 --- a/Sources/agentc/SharedOptions.swift +++ b/Sources/agentc/SharedOptions.swift @@ -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[.. [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. diff --git a/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeTests.swift b/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeTests.swift index 8c814f1..0565046 100644 --- a/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeTests.swift +++ b/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeTests.swift @@ -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 diff --git a/Tests/AgentIsolationTests/AgentSessionTests.swift b/Tests/AgentIsolationTests/AgentSessionTests.swift index ea3a2f7..19b56b9 100644 --- a/Tests/AgentIsolationTests/AgentSessionTests.swift +++ b/Tests/AgentIsolationTests/AgentSessionTests.swift @@ -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")) diff --git a/Tests/AgentIsolationTests/ProjectSettingsTests.swift b/Tests/AgentIsolationTests/ProjectSettingsTests.swift index 314d682..075cc6b 100644 --- a/Tests/AgentIsolationTests/ProjectSettingsTests.swift +++ b/Tests/AgentIsolationTests/ProjectSettingsTests.swift @@ -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, @@ -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) @@ -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) diff --git a/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift b/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift index f6acada..6b6069b 100644 --- a/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift +++ b/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift @@ -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) @@ -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") diff --git a/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift b/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift index 27ca23a..90f5e8b 100644 --- a/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift +++ b/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift @@ -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") @@ -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") diff --git a/docs/project-settings.md b/docs/project-settings.md index a4d8a05..a6b097f 100644 --- a/docs/project-settings.md +++ b/docs/project-settings.md @@ -44,6 +44,9 @@ All fields are optional. Only the values you specify take effect. "excludes": ["", ...], "configurations": ["", ...], "additionalMounts": ["", ...], + "environment": { + "": "" + }, "defaultArguments": ["", ...], "additionalArguments": ["", ...], "cpus": "", @@ -63,6 +66,7 @@ All fields are optional. Only the values you specify take effect. | `agent.excludes` | `--exclude` | Workspace sub-folders to mask with empty overlays. | | `agent.configurations` | `--configurations`, `-c` | Agent configuration names to activate. | | `agent.additionalMounts` | `--additional-mount` | Additional host directories to mount. | +| `agent.environment` | `--env`, `-e` | Environment variables passed to the container. | | `agent.defaultArguments` | positional args after `--` | Default arguments passed to the entrypoint. | | `agent.additionalArguments` | *(none)* | Arguments always appended to entrypoint args. | | `agent.cpus` | `--cpus` | Number of CPUs to allocate. | @@ -82,6 +86,7 @@ When both CLI flags and project settings specify a value, the behavior depends o - `excludes` — CLI and project excludes are all applied. - `additionalMounts` — CLI and project mounts are all mounted. +- `environment` — Variables are merged by name; CLI values override matching project values. **Arguments have special handling:** @@ -124,6 +129,29 @@ For fields with override behavior, the full priority chain is: } ``` +### Customize Locale and Time Zone + +```json +{ + "agent": { + "environment": { + "TZ": "America/Los_Angeles", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US.UTF-8" + } + } +} +``` + +Use repeatable CLI options for one-off overrides: + +```sh +agentc run --env TZ=America/Los_Angeles --env LC_ALL=en_US.UTF-8 +``` + +Project settings provide defaults. A CLI value with the same name takes precedence. +Variables used internally by agentc (`AGENTC_*`) are reserved and cannot be overridden. + ### Use Image Entrypoint (No Bootstrap) ```json