diff --git a/Package.swift b/Package.swift index 3a24b7971..09d366b76 100644 --- a/Package.swift +++ b/Package.swift @@ -353,8 +353,9 @@ let package = Package( dependencies: [ .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), - "ContainerAPIService", + .product(name: "ContainerizationOCI", package: "containerization"), "ContainerResource", + "ContainerAPIService", ] ), .target( @@ -393,6 +394,7 @@ let package = Package( dependencies: [ .product(name: "Containerization", package: "containerization"), "ContainerResource", + "ContainerSandboxService", "ContainerSandboxServiceClient", ] ), diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift index c83fbf790..2a586d588 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -128,7 +128,11 @@ extension Application { try? io.close() } - let process = try await client.bootstrap(id: id, stdio: io.stdio) + var dynamicEnv: [String: String] = [:] + if ck.0.ssh, let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] { + dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock + } + let process = try await client.bootstrap(id: id, stdio: io.stdio, dynamicEnv: dynamicEnv) progress.finish() if !self.managementFlags.cidfile.isEmpty { diff --git a/Sources/ContainerCommands/Container/ContainerStart.swift b/Sources/ContainerCommands/Container/ContainerStart.swift index 4067df8cf..bfa07a44c 100644 --- a/Sources/ContainerCommands/Container/ContainerStart.swift +++ b/Sources/ContainerCommands/Container/ContainerStart.swift @@ -87,7 +87,11 @@ extension Application { try? io.close() } - let process = try await client.bootstrap(id: container.id, stdio: io.stdio) + var dynamicEnv: [String: String] = [:] + if container.configuration.ssh, let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] { + dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock + } + let process = try await client.bootstrap(id: container.id, stdio: io.stdio, dynamicEnv: dynamicEnv) progress.finish() if detach { diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index ec50d91c0..217303d39 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -113,7 +113,8 @@ public struct ContainerClient: Sendable { } /// Bootstrap the container's init process. - public func bootstrap(id: String, stdio: [FileHandle?]) async throws -> ClientProcess { + /// - Parameter dynamicEnv: Optional start-time environment overrides passed through bootstrap. + public func bootstrap(id: String, stdio: [FileHandle?], dynamicEnv: [String: String] = [:]) async throws -> ClientProcess { let request = XPCMessage(route: .containerBootstrap) for (i, h) in stdio.enumerated() { @@ -134,6 +135,10 @@ public struct ContainerClient: Sendable { do { request.set(key: .id, value: id) + if !dynamicEnv.isEmpty { + let encodedDynamicEnv = try JSONEncoder().encode(dynamicEnv) + request.set(key: .dynamicEnv, value: encodedDynamicEnv) + } try await xpcClient.send(request) return ClientProcessImpl(containerId: id, xpcClient: xpcClient) } catch { diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 495a227fd..39a23a138 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -112,6 +112,9 @@ public enum XPCKeys: String { /// Init image reference case initImage + /// SSH agent socket path supplied at bootstrap time (current client shell). + case dynamicEnv + /// Volume case volume case volumes diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index c315c3e08..38922430f 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -55,7 +55,13 @@ public struct ContainersHarness: Sendable { ) } let stdio = message.stdio() - try await service.bootstrap(id: id, stdio: stdio) + let dynamicEnv: [String: String] = + if let data = message.dataNoCopy(key: .dynamicEnv) { + try JSONDecoder().decode([String: String].self, from: data) + } else { + [:] + } + try await service.bootstrap(id: id, stdio: stdio, dynamicEnv: dynamicEnv) return message.reply() } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index b266116fb..a4e7660f0 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -398,7 +398,8 @@ public actor ContainersService { } /// Bootstrap the init process of the container. - public func bootstrap(id: String, stdio: [FileHandle?]) async throws { + /// - Parameter dynamicEnv: Optional start-time environment overrides passed from the client. + public func bootstrap(id: String, stdio: [FileHandle?], dynamicEnv: [String: String] = [:]) async throws { log.debug( "ContainersService: enter", metadata: [ @@ -473,7 +474,7 @@ public actor ContainersService { id: id, runtime: runtime ) - try await sandboxClient.bootstrap(stdio: stdio, allocatedAttachments: allocatedAttachments) + try await sandboxClient.bootstrap(stdio: stdio, allocatedAttachments: allocatedAttachments, dynamicEnv: dynamicEnv) try await self.exitMonitor.registerProcess( id: id, diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift index 6f1cdd2d8..125563a27 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift @@ -77,7 +77,8 @@ public struct SandboxClient: Sendable { // Runtime Methods extension SandboxClient { - public func bootstrap(stdio: [FileHandle?], allocatedAttachments: [AllocatedAttachment]) async throws { + /// - Parameter dynamicEnv: Optional start-time environment overrides passed from the API service. + public func bootstrap(stdio: [FileHandle?], allocatedAttachments: [AllocatedAttachment], dynamicEnv: [String: String] = [:]) async throws { let request = XPCMessage(route: SandboxRoutes.bootstrap.rawValue) for (i, h) in stdio.enumerated() { @@ -96,6 +97,11 @@ extension SandboxClient { } } + if !dynamicEnv.isEmpty { + let encodedDynamicEnv = try JSONEncoder().encode(dynamicEnv) + request.set(key: SandboxKeys.dynamicEnv.rawValue, value: encodedDynamicEnv) + } + do { try request.setAllocatedAttachments(allocatedAttachments) try await self.client.send(request) diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift index e207cb049..77f3ddb55 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift @@ -43,6 +43,9 @@ public enum SandboxKeys: String { /// Container statistics case statistics + /// SSH agent socket path supplied at bootstrap time (current client shell). + case dynamicEnv + /// Network resource keys. case allocatedAttachments case networkAdditionalData diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index db8acd03c..c9fc9561b 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -75,13 +75,78 @@ public actor SandboxService { } } - private static func sshAuthSocketHostUrl(config: ContainerConfiguration) -> URL? { - if config.ssh, let sshSocket = Foundation.ProcessInfo.processInfo.environment[Self.sshAuthSocketEnvVar] { - return URL(fileURLWithPath: sshSocket) + private enum SSHAuthSocketSource: String { + /// Path supplied by client at bootstrap time (current shell's SSH_AUTH_SOCK). + case bootstrap = "bootstrap" + } + + private static func isUnixSocket(path: String) -> Bool { + (try? File.info(path).isSocket) ?? false + } + + /// Resolves the host path for SSH agent socket forwarding. Uses only the path passed by the + /// client at bootstrap time; the sandbox does not read SSH_AUTH_SOCK from its own environment + /// or launchctl (the client is responsible for passing the current value). + private static func resolveSSHAuthSocketHostPath(config: ContainerConfiguration, bootstrapOverridePath: String? = nil) -> (path: String, source: SSHAuthSocketSource)? { + guard config.ssh else { + return nil + } + + if let bootstrapOverridePath, + Self.isUnixSocket(path: bootstrapOverridePath) + { + return (bootstrapOverridePath, .bootstrap) } + return nil } + private static func sshAuthSocketHostUrl(config: ContainerConfiguration, bootstrapOverridePath: String? = nil) -> URL? { + guard let resolved = Self.resolveSSHAuthSocketHostPath(config: config, bootstrapOverridePath: bootstrapOverridePath) else { + return nil + } + return URL(fileURLWithPath: resolved.path) + } + + /// Merges start-time environment overrides into a base environment list. + /// Existing keys are replaced in-place and new keys are appended. + /// Made static for unit testability. + public static func mergedEnvironmentVariables(base: [String], overrides: [String: String]) -> [String] { + guard !overrides.isEmpty else { + return base + } + + var env = base + var pending = overrides + + for (index, entry) in env.enumerated() { + guard let separator = entry.firstIndex(of: "=") else { + continue + } + + let key = String(entry[.. [String] { @@ -916,17 +1023,21 @@ public actor SandboxService { private static func configureInitialProcess( czConfig: inout LinuxContainer.Configuration, - config: ContainerConfiguration + config: ContainerConfiguration, + bootstrapOverridePath: String? = nil, + dynamicEnv: [String: String] = [:] ) throws { let process = config.initProcess czConfig.process.arguments = [process.executable] + process.arguments - czConfig.process.environmentVariables = process.environment + czConfig.process.environmentVariables = Self.mergedEnvironmentVariables( + base: process.environment, + overrides: dynamicEnv + ) - if Self.sshAuthSocketHostUrl(config: config) != nil { - if !czConfig.process.environmentVariables.contains(where: { $0.starts(with: "\(Self.sshAuthSocketEnvVar)=") }) { - czConfig.process.environmentVariables.append("\(Self.sshAuthSocketEnvVar)=\(Self.sshAuthSocketGuestPath)") - } + if Self.sshAuthSocketHostUrl(config: config, bootstrapOverridePath: bootstrapOverridePath) != nil { + czConfig.process.environmentVariables.removeAll(where: { $0.starts(with: "\(Self.sshAuthSocketEnvVar)=") }) + czConfig.process.environmentVariables.append("\(Self.sshAuthSocketEnvVar)=\(Self.sshAuthSocketGuestPath)") } czConfig.process.terminal = process.terminal diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift index e5448b45b..510dcb6eb 100644 --- a/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerizationError +import Foundation import Testing class TestCLIRunLifecycle: CLITest { @@ -138,4 +139,43 @@ class TestCLIRunLifecycle: CLITest { ) } } + + @Test func testRunStartWithSSHSetsGuestAuthSockPath() throws { + guard ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil else { + return + } + + let name = getTestName() + defer { + try? doStop(name: name) + try? doRemove(name: name, force: true) + } + + try doLongRun(name: name, args: ["--ssh"], autoRemove: false) + try waitForContainerRunning(name) + + var output = try doExec(name: name, cmd: ["sh", "-lc", "echo -n ${SSH_AUTH_SOCK:-}"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "/run/host-services/ssh-auth.sock") + + try doStop(name: name) + try doStart(name: name) + try waitForContainerRunning(name) + + output = try doExec(name: name, cmd: ["sh", "-lc", "echo -n ${SSH_AUTH_SOCK:-}"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "/run/host-services/ssh-auth.sock") + } + + @Test func testRunWithoutSSHDoesNotSetGuestAuthSockPath() throws { + let name = getTestName() + defer { + try? doStop(name: name) + try? doRemove(name: name, force: true) + } + + try doLongRun(name: name, autoRemove: false) + try waitForContainerRunning(name) + + let output = try doExec(name: name, cmd: ["sh", "-lc", "echo -n ${SSH_AUTH_SOCK:-}"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } } diff --git a/Tests/ContainerResourceTests/SSHConfigTests.swift b/Tests/ContainerResourceTests/SSHConfigTests.swift new file mode 100644 index 000000000..1073c3760 --- /dev/null +++ b/Tests/ContainerResourceTests/SSHConfigTests.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationOCI +import Foundation +import Testing + +@testable import ContainerResource + +/// Unit tests for SSH agent forwarding configuration (`ssh`). +/// The SSH agent socket path is supplied at bootstrap time by the client, not stored in config. +struct SSHConfigTests { + + @Test("SSH config round-trip: ssh true is preserved after encode/decode") + func sshConfigRoundTripPreservesTrue() throws { + var config = makeMinimalConfig() + config.ssh = true + + let encoded = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: encoded) + + #expect(decoded.ssh == true) + } + + @Test("SSH config round-trip: ssh false is preserved") + func sshConfigRoundTripPreservesFalse() throws { + let config = makeMinimalConfig() + #expect(config.ssh == false) + + let encoded = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: encoded) + + #expect(decoded.ssh == false) + } + + @Test("SSH config decode: missing ssh defaults to false") + func sshConfigDecodeDefaults() throws { + let minimalJSON = """ + { + "id": "test", + "image": {"reference": "alpine", "descriptor": {"digest": "sha256:test", "mediaType": "application/vnd.oci.image.manifest.v1+json", "size": 0}}, + "initProcess": { + "executable": "/bin/sh", + "arguments": [], + "environment": [], + "workingDirectory": "/", + "terminal": false, + "user": {"id": {"uid": 0, "gid": 0}}, + "supplementalGroups": [], + "rlimits": [] + } + } + """ + let data = minimalJSON.data(using: .utf8)! + let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: data) + + #expect(decoded.ssh == false) + } + + private func makeMinimalConfig() -> ContainerConfiguration { + let descriptor = Descriptor( + mediaType: "application/vnd.oci.image.manifest.v1+json", + digest: "sha256:test", + size: 0 + ) + let image = ImageDescription(reference: "alpine", descriptor: descriptor) + let process = ProcessConfiguration( + executable: "/bin/sh", + arguments: [], + environment: [], + workingDirectory: "/", + terminal: false, + user: .id(uid: 0, gid: 0), + supplementalGroups: [], + rlimits: [] + ) + return ContainerConfiguration(id: "test", image: image, process: process) + } +} diff --git a/Tests/ContainerSandboxServiceTests/DynamicEnvMergeTests.swift b/Tests/ContainerSandboxServiceTests/DynamicEnvMergeTests.swift new file mode 100644 index 000000000..1cd9e1548 --- /dev/null +++ b/Tests/ContainerSandboxServiceTests/DynamicEnvMergeTests.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerSandboxService +import Testing + +struct DynamicEnvMergeTests { + @Test + func testDynamicEnvMergeAddsMissingKey() { + let overrides = ["SSH_AUTH_SOCK": "/run/host-services/ssh-auth.sock"] + let base = ["PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"] + + let mergedEnv = SandboxService.mergedEnvironmentVariables(base: base, overrides: overrides) + + #expect(mergedEnv.contains("PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin")) + #expect(mergedEnv.contains("SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock")) + } + + @Test + func testDynamicEnvMergeOverridesExistingKey() { + let overrides = ["FOO": "updated"] + let base = ["FOO=original", "PATH=/usr/bin"] + + let mergedEnv = SandboxService.mergedEnvironmentVariables(base: base, overrides: overrides) + + #expect(mergedEnv.contains("FOO=updated")) + #expect(!mergedEnv.contains("FOO=original")) + #expect(mergedEnv.contains("PATH=/usr/bin")) + } + + @Test + func testDynamicEnvMergeNoOverridesLeavesBaseUnchanged() { + let base = ["FOO=bar", "PATH=/usr/bin"] + let mergedEnv = SandboxService.mergedEnvironmentVariables(base: base, overrides: [:]) + #expect(mergedEnv == base) + } +}