Skip to content
Open
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
4 changes: 3 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -393,6 +394,7 @@ let package = Package(
dependencies: [
.product(name: "Containerization", package: "containerization"),
"ContainerResource",
"ContainerSandboxService",
"ContainerSandboxServiceClient",
]
),
Expand Down
6 changes: 5 additions & 1 deletion Sources/ContainerCommands/Container/ContainerRun.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion Sources/ContainerCommands/Container/ContainerStart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -134,6 +135,10 @@ public struct ContainerClient: Sendable {

do {
request.set(key: .id, value: id)
if !dynamicEnv.isEmpty {
Copy link
Contributor

@JaewonHur JaewonHur Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better just keep it simple: encode dynamicEnv always (assuming it's not that performance heavy operation).

let encodedDynamicEnv = try JSONEncoder().encode(dynamicEnv)
request.set(key: .dynamicEnv, value: encodedDynamicEnv)
}
try await xpcClient.send(request)
return ClientProcessImpl(containerId: id, xpcClient: xpcClient)
} catch {
Expand Down
3 changes: 3 additions & 0 deletions Sources/Services/ContainerAPIService/Client/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ public struct ContainersHarness: Sendable {
)
}
let stdio = message.stdio()
try await service.bootstrap(id: id, stdio: stdio)
let dynamicEnv: [String: String] =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here the same?

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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need default value for dynamicEnv.

log.debug(
"ContainersService: enter",
metadata: [
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need default value.

let request = XPCMessage(route: SandboxRoutes.bootstrap.rawValue)

for (i, h) in stdio.enumerated() {
Expand All @@ -96,6 +97,11 @@ extension SandboxClient {
}
}

if !dynamicEnv.isEmpty {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be better to keep it simple?

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 124 additions & 13 deletions Sources/Services/ContainerSandboxService/Server/SandboxService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to return source here? I found this only being used in L228?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveSSHAuthSocketHostPath and sshAuthSocketHostURL are seemingly for same purpose?

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[..<separator])
if let overrideValue = pending.removeValue(forKey: key) {
env[index] = "\(key)=\(overrideValue)"
}
}

for key in pending.keys.sorted() {
guard let value = pending[key] else {
continue
}
env.append("\(key)=\(value)")
}

return env
}

/// Create an instance with a bundle that describes the container.
///
/// - Parameters:
/// - root: The file URL for the bundle root.
/// - interfaceStrategy: The strategy for producing network interface
/// objects for each network to which the container attaches.
/// - log: The destination for log messages.
public init(
root: URL,
interfaceStrategies: [NetworkPluginInfo: InterfaceStrategy],
Expand Down Expand Up @@ -144,6 +209,36 @@ public actor SandboxService {
try bundle.createLogFile()

var config = try bundle.configuration
// Extract dynamic env overrides from the XPC request; when SSH forwarding is enabled,
// SSH_AUTH_SOCK from this map provides the host socket path to mount.
let dynamicEnv: [String: String] =
if let data = message.dataNoCopy(key: SandboxKeys.dynamicEnv.rawValue) {
try JSONDecoder().decode([String: String].self, from: data)
} else {
[:]
}
let bootstrapSshAuthPath = dynamicEnv[Self.sshAuthSocketEnvVar]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit complicated both bootstrapSshAuthPath and dynamicEnv are flowing around. Could we remain only dynamicEnv and use it in Self.resolveSSHAuthSocketHostPath(config: config, dynamicEnv: dynamicEnv) to figure out the SSH_AUTH_SOCKET path?


if config.ssh {
if let resolved = Self.resolveSSHAuthSocketHostPath(config: config, bootstrapOverridePath: bootstrapSshAuthPath) {
self.log.info(
"ssh agent forwarding requested",
metadata: [
"hostSocketPath": "\(resolved.path)",
"hostSocketSource": "\(resolved.source.rawValue)",
"guestSocketPath": "\(Self.sshAuthSocketGuestPath)",
]
)
} else {
self.log.warning(
"ssh agent forwarding requested but no valid SSH_AUTH_SOCK source found",
metadata: [
"envVar": "\(Self.sshAuthSocketEnvVar)",
"bootstrapPath": "\(bootstrapSshAuthPath ?? "")",
]
)
}
}

var kernel = try bundle.kernel
kernel.commandLine.kernelArgs.append("oops=panic")
Expand Down Expand Up @@ -215,7 +310,12 @@ public actor SandboxService {
let id = config.id
let rootfs = try bundle.containerRootfs.asMount
let container = try LinuxContainer(id, rootfs: rootfs, vmm: vmm, logger: self.log) { czConfig in
try Self.configureContainer(czConfig: &czConfig, config: config)
try Self.configureContainer(
czConfig: &czConfig,
config: config,
bootstrapOverridePath: bootstrapSshAuthPath,
dynamicEnv: dynamicEnv
)
czConfig.interfaces = interfaces
czConfig.process.stdout = stdout
czConfig.process.stderr = stderr
Expand Down Expand Up @@ -840,7 +940,9 @@ public actor SandboxService {

private static func configureContainer(
czConfig: inout LinuxContainer.Configuration,
config: ContainerConfiguration
config: ContainerConfiguration,
bootstrapOverridePath: String? = nil,
dynamicEnv: [String: String] = [:]
) throws {
czConfig.cpus = config.resources.cpus
czConfig.memoryInBytes = config.resources.memoryInBytes
Expand Down Expand Up @@ -873,7 +975,7 @@ public actor SandboxService {
czConfig.sockets.append(socketConfig)
}

if let socketUrl = Self.sshAuthSocketHostUrl(config: config) {
if let socketUrl = Self.sshAuthSocketHostUrl(config: config, bootstrapOverridePath: bootstrapOverridePath) {
let socketPath = socketUrl.path(percentEncoded: false)
let attrs = try? FileManager.default.attributesOfItem(atPath: socketPath)
let permissions = (attrs?[.posixPermissions] as? NSNumber)
Expand All @@ -899,7 +1001,12 @@ public actor SandboxService {
searchDomains: dns.searchDomains, options: dns.options)
}

try Self.configureInitialProcess(czConfig: &czConfig, config: config)
try Self.configureInitialProcess(
czConfig: &czConfig,
config: config,
bootstrapOverridePath: bootstrapOverridePath,
dynamicEnv: dynamicEnv
)
}

private func getDefaultNameservers(allocatedAttachments: [AllocatedAttachment]) async throws -> [String] {
Expand All @@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think dynamicEnv might not to be merged here for container's env variables?
I guess the reason we pass dynamicEnv from CLI side is to guide the server-side logic from the user-predicted shell environment.
If a user wants to feed env variables to the container they explicitly specify those using --env flags.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit comment, hostEnv might be the better name for dynamicEnv.

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
Expand Down
40 changes: 40 additions & 0 deletions Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
//===----------------------------------------------------------------------===//

import ContainerizationError
import Foundation
import Testing

class TestCLIRunLifecycle: CLITest {
Expand Down Expand Up @@ -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)
}
}
Loading