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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ website/site/
website/docs/*.md

install.sh

# Kaiden workspace configuration
.kaiden/
15 changes: 14 additions & 1 deletion pkg/runtime/podman/containerfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func generateSudoers(sudoBinaries []string) string {
// that install scripts can chown files, write to /home/agent, and su to the target user.
// _REMOTE_USER and _REMOTE_USER_HOME are exported before the feature block so install
// scripts can resolve the target user by name. USER agent:agent is set after all features.
func generateContainerfile(imageConfig *config.ImageConfig, agentConfig *config.AgentConfig, hasAgentSettings bool, featureInfos []featureInstallInfo) string {
func generateContainerfile(imageConfig *config.ImageConfig, agentConfig *config.AgentConfig, hasAgentSettings bool, featureInfos []featureInstallInfo, certsCopied bool) string {
if imageConfig == nil {
return ""
}
Expand All @@ -107,10 +107,23 @@ func generateContainerfile(imageConfig *config.ImageConfig, agentConfig *config.
lines = append(lines, "ARG GID=1000")
lines = append(lines, "")

// Copy and install system CA certificates for enterprise proxy support.
// This enables containers to trust self-signed certificates from corporate proxies
// like Netskope during package installation (dnf install, curl, etc.).
// Only add COPY instructions when certificates are actually available in the build context.
if certsCopied {
lines = append(lines, "COPY certs/system-ca.crt /tmp/system-ca.crt")
lines = append(lines, "RUN cp /tmp/system-ca.crt /etc/pki/ca-trust/source/anchors/system-ca.crt && update-ca-trust")
lines = append(lines, "")
}

// Merge packages from image and agent configs
allPackages := append([]string{}, imageConfig.Packages...)
allPackages = append(allPackages, agentConfig.Packages...)

// Always install nftables for network guard firewall functionality
allPackages = append(allPackages, "nftables")

// Install packages if any
if len(allPackages) > 0 {
lines = append(lines, fmt.Sprintf("RUN dnf install -y %s", strings.Join(allPackages, " ")))
Expand Down
69 changes: 49 additions & 20 deletions pkg/runtime/podman/containerfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func TestGenerateContainerfile(t *testing.T) {
TerminalCommand: []string{"claude"},
}

result := generateContainerfile(imageConfig, agentConfig, false, nil)
result := generateContainerfile(imageConfig, agentConfig, false, nil, false)

// Check for FROM line with correct base image
expectedFrom := "FROM registry.fedoraproject.org/fedora:latest"
Expand Down Expand Up @@ -161,7 +161,7 @@ func TestGenerateContainerfile(t *testing.T) {
TerminalCommand: []string{"claude"},
}

result := generateContainerfile(imageConfig, agentConfig, false, nil)
result := generateContainerfile(imageConfig, agentConfig, false, nil, false)

expectedFrom := "FROM registry.fedoraproject.org/fedora:40"
if !strings.Contains(result, expectedFrom) {
Expand All @@ -184,7 +184,7 @@ func TestGenerateContainerfile(t *testing.T) {
TerminalCommand: []string{"claude"},
}

result := generateContainerfile(imageConfig, agentConfig, false, nil)
result := generateContainerfile(imageConfig, agentConfig, false, nil, false)

// Should have all packages in a single RUN command
if !strings.Contains(result, "RUN dnf install -y package1 package2 package3 package4") {
Expand All @@ -207,11 +207,14 @@ func TestGenerateContainerfile(t *testing.T) {
TerminalCommand: []string{"claude"},
}

result := generateContainerfile(imageConfig, agentConfig, false, nil)
result := generateContainerfile(imageConfig, agentConfig, false, nil, false)

// Should not have dnf install line
if strings.Contains(result, "dnf install") {
t.Error("Expected no dnf install line when no packages specified")
// Should have nftables in dnf install line (pre-installed for deny-mode networking)
if !strings.Contains(result, "dnf install") {
t.Error("Expected dnf install line for nftables")
}
if !strings.Contains(result, "nftables") {
t.Error("Expected nftables to be in the package list")
}
})

Expand All @@ -231,7 +234,7 @@ func TestGenerateContainerfile(t *testing.T) {
TerminalCommand: []string{"claude"},
}

result := generateContainerfile(imageConfig, agentConfig, false, nil)
result := generateContainerfile(imageConfig, agentConfig, false, nil, false)

// Should have both RUN commands
if !strings.Contains(result, "RUN echo 'image setup'") {
Expand All @@ -257,7 +260,7 @@ func TestGenerateContainerfile(t *testing.T) {
TerminalCommand: []string{"claude"},
}

result := generateContainerfile(imageConfig, agentConfig, true, nil)
result := generateContainerfile(imageConfig, agentConfig, true, nil, false)

expected := "COPY --chown=agent:agent agent-settings/. /home/agent/"
if !strings.Contains(result, expected) {
Expand Down Expand Up @@ -290,7 +293,7 @@ func TestGenerateContainerfile(t *testing.T) {
TerminalCommand: []string{"claude"},
}

result := generateContainerfile(imageConfig, agentConfig, false, nil)
result := generateContainerfile(imageConfig, agentConfig, false, nil, false)

if strings.Contains(result, "agent-settings") {
t.Errorf("Expected no agent-settings COPY line, got:\n%s", result)
Expand All @@ -313,7 +316,7 @@ func TestGenerateContainerfile(t *testing.T) {
TerminalCommand: []string{"claude"},
}

result := generateContainerfile(imageConfig, agentConfig, false, nil)
result := generateContainerfile(imageConfig, agentConfig, false, nil, false)

// Find positions
imagePos := strings.Index(result, "RUN echo 'image'")
Expand Down Expand Up @@ -398,7 +401,7 @@ func TestGenerateContainerfile_WithFeatures(t *testing.T) {
{dirName: "feature-0", options: nil, envVars: nil},
}

result := generateContainerfile(imageConfig, agentConfig, false, infos)
result := generateContainerfile(imageConfig, agentConfig, false, infos, false)

if !strings.Contains(result, "COPY features/feature-0/ /tmp/feature-install/feature-0/") {
t.Error("Expected COPY instruction for feature-0")
Expand Down Expand Up @@ -440,7 +443,7 @@ func TestGenerateContainerfile_WithFeatures(t *testing.T) {
{dirName: "feature-0", options: map[string]string{"VERSION": "1.21", "INSTALL": "true"}, envVars: nil},
}

result := generateContainerfile(imageConfig, agentConfig, false, infos)
result := generateContainerfile(imageConfig, agentConfig, false, infos, false)

expected := `RUN chmod +x /tmp/feature-install/feature-0/install.sh && INSTALL="true" VERSION="1.21" /tmp/feature-install/feature-0/install.sh`
if !strings.Contains(result, expected) {
Expand All @@ -459,7 +462,7 @@ func TestGenerateContainerfile_WithFeatures(t *testing.T) {
},
}

result := generateContainerfile(imageConfig, agentConfig, false, infos)
result := generateContainerfile(imageConfig, agentConfig, false, infos, false)

if !strings.Contains(result, `ENV GOPATH="/home/agent/go"`) {
t.Errorf("Expected ENV GOPATH instruction\nGot:\n%s", result)
Expand All @@ -476,7 +479,7 @@ func TestGenerateContainerfile_WithFeatures(t *testing.T) {
{dirName: "feature-0", options: nil, envVars: nil},
}

result := generateContainerfile(imageConfig, agentConfig, false, infos)
result := generateContainerfile(imageConfig, agentConfig, false, infos, false)

featureCopyPos := strings.Index(result, "COPY features/feature-0/")
imageRunPos := strings.Index(result, "RUN echo 'image setup'")
Expand All @@ -498,7 +501,7 @@ func TestGenerateContainerfile_WithFeatures(t *testing.T) {
{dirName: "feature-1", options: nil, envVars: nil},
}

result := generateContainerfile(imageConfig, agentConfig, false, infos)
result := generateContainerfile(imageConfig, agentConfig, false, infos, false)

feature0Pos := strings.Index(result, "COPY features/feature-0/")
feature1Pos := strings.Index(result, "COPY features/feature-1/")
Expand All @@ -519,7 +522,7 @@ func TestGenerateContainerfile_WithFeatures(t *testing.T) {
t.Run("no feature instructions when featureInfos is nil", func(t *testing.T) {
t.Parallel()

result := generateContainerfile(imageConfig, agentConfig, false, nil)
result := generateContainerfile(imageConfig, agentConfig, false, nil, false)

if strings.Contains(result, "COPY features/") {
t.Errorf("Expected no feature COPY instructions\nGot:\n%s", result)
Expand All @@ -536,7 +539,7 @@ func TestGenerateContainerfile_WithFeatures(t *testing.T) {
{dirName: "feature-0", options: nil, envVars: nil},
}

result := generateContainerfile(imageConfig, agentConfig, false, infos)
result := generateContainerfile(imageConfig, agentConfig, false, infos, false)

if !strings.Contains(result, `ENV _REMOTE_USER="agent"`) {
t.Errorf("Expected ENV _REMOTE_USER=\"agent\"\nGot:\n%s", result)
Expand Down Expand Up @@ -564,7 +567,7 @@ func TestGenerateContainerfile_WithFeatures(t *testing.T) {
t.Run("_REMOTE_USER and _REMOTE_USER_HOME are not set when no features", func(t *testing.T) {
t.Parallel()

result := generateContainerfile(imageConfig, agentConfig, false, nil)
result := generateContainerfile(imageConfig, agentConfig, false, nil, false)

if strings.Contains(result, "_REMOTE_USER") {
t.Errorf("Expected no _REMOTE_USER when no features\nGot:\n%s", result)
Expand All @@ -578,7 +581,7 @@ func TestGenerateContainerfile_WithFeatures(t *testing.T) {
{dirName: "feature-0", options: nil, envVars: nil},
}

result := generateContainerfile(imageConfig, agentConfig, true, infos)
result := generateContainerfile(imageConfig, agentConfig, true, infos, false)

featureCopyPos := strings.Index(result, "COPY features/feature-0/")
agentSettingsPos := strings.Index(result, "COPY --chown=agent:agent agent-settings/.")
Expand All @@ -590,4 +593,30 @@ func TestGenerateContainerfile_WithFeatures(t *testing.T) {
t.Error("Expected feature COPY to appear before agent-settings COPY")
}
})

t.Run("includes CA certificate COPY when certsCopied is true", func(t *testing.T) {
t.Parallel()

result := generateContainerfile(imageConfig, agentConfig, false, nil, true)

if !strings.Contains(result, "COPY certs/system-ca.crt /tmp/system-ca.crt") {
t.Error("Expected CA certificate COPY instruction when certsCopied is true")
}
if !strings.Contains(result, "RUN cp /tmp/system-ca.crt /etc/pki/ca-trust/source/anchors/system-ca.crt && update-ca-trust") {
t.Error("Expected CA certificate installation RUN instruction when certsCopied is true")
}
})

t.Run("omits CA certificate COPY when certsCopied is false", func(t *testing.T) {
t.Parallel()

result := generateContainerfile(imageConfig, agentConfig, false, nil, false)

if strings.Contains(result, "COPY certs/system-ca.crt") {
t.Error("Expected no CA certificate COPY instruction when certsCopied is false")
}
if strings.Contains(result, "update-ca-trust") {
t.Error("Expected no update-ca-trust when certsCopied is false")
}
})
}
99 changes: 96 additions & 3 deletions pkg/runtime/podman/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type podTemplateData struct {
AgentUID int
BaseImageRegistry string
BaseImageVersion string
WorkspaceImage string // The built workspace image with nftables pre-installed
SourcePath string
ProjectID string
Agent string
Expand Down Expand Up @@ -92,7 +93,8 @@ func (p *podmanRuntime) createInstanceDirectory(name string) (string, error) {
// so they can be embedded in the image via a COPY instruction.
// If featureInfos is non-empty, the features have already been downloaded to instanceDir/features/
// and the Containerfile will include instructions to install them.
func (p *podmanRuntime) createContainerfile(instanceDir string, imageConfig *config.ImageConfig, agentConfig *config.AgentConfig, settings map[string]agent.SettingsFile, featureInfos []featureInstallInfo) error {
// If certsCopied is true, the Containerfile will include instructions to install system CA certificates.
func (p *podmanRuntime) createContainerfile(instanceDir string, imageConfig *config.ImageConfig, agentConfig *config.AgentConfig, settings map[string]agent.SettingsFile, featureInfos []featureInstallInfo, certsCopied bool) error {
// Generate sudoers content
sudoersContent := generateSudoers(imageConfig.Sudo)
sudoersPath := filepath.Join(instanceDir, "sudoers")
Expand Down Expand Up @@ -122,7 +124,7 @@ func (p *podmanRuntime) createContainerfile(instanceDir string, imageConfig *con
}

// Generate Containerfile content
containerfileContent := generateContainerfile(imageConfig, agentConfig, len(settings) > 0, featureInfos)
containerfileContent := generateContainerfile(imageConfig, agentConfig, len(settings) > 0, featureInfos, certsCopied)
containerfilePath := filepath.Join(instanceDir, "Containerfile")
if err := os.WriteFile(containerfilePath, []byte(containerfileContent), 0644); err != nil {
return fmt.Errorf("failed to write Containerfile: %w", err)
Expand All @@ -131,6 +133,88 @@ func (p *podmanRuntime) createContainerfile(instanceDir string, imageConfig *con
return nil
}

// findSystemCACertificates attempts to read system CA certificates from common locations.
// Returns the certificate content and true if found, or nil and false otherwise.
func findSystemCACertificates(certPaths []string) ([]byte, bool) {
var certContent []byte

// Get the system CA certificates from the location pointed by SSL_CERT_FILE if it's set
if caBundlePath := os.Getenv("SSL_CERT_FILE"); caBundlePath != "" {
if abs, err := filepath.Abs(caBundlePath); err == nil {
caBundlePath = abs
}
// Skip directories
if info, err := os.Stat(caBundlePath); err == nil && !info.IsDir() {
if content, err := os.ReadFile(caBundlePath); err == nil && len(content) > 0 {
certContent = content
}
}
}

if len(certContent) == 0 {
for _, path := range certPaths {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
// Skip directories to avoid reading random files
if info, err := os.Stat(path); err == nil && info.IsDir() {
continue
}
if content, err := os.ReadFile(path); err == nil && len(content) > 0 {
certContent = content
Comment thread
coderabbitai[bot] marked this conversation as resolved.
break
}
}
}

if len(certContent) == 0 {
return nil, false
}
return certContent, true
}

// systemCACertPaths lists common system CA bundle locations across Linux distributions.
var systemCACertPaths = []string{
// Debian / Ubuntu / Gentoo
"/etc/ssl/certs/ca-certificates.crt",
// Fedora / RHEL 6
"/etc/pki/tls/certs/ca-bundle.crt",
// Fedora / RHEL 7+ / CentOS / Rocky / Alma
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
// OpenSUSE
"/etc/ssl/ca-bundle.pem",
// SUSE / older distributions
"/etc/ssl/certs/ca-bundle.crt",
// Alpine Linux
"/etc/ssl/cert.pem",
// Arch Linux
"/etc/ca-certificates/extracted/tls-ca-bundle.pem",
}

// copySystemCACertificates copies system CA certificates to the build context for
// enterprise proxy support (enables trusting self-signed certs during build).
// certPaths is the ordered list of candidate paths; pass systemCACertPaths for production
// or a custom list in tests. Returns true if certificates were copied, false otherwise.
func (p *podmanRuntime) copySystemCACertificates(instanceDir string, certPaths []string) (bool, error) {
certContent, found := findSystemCACertificates(certPaths)
if !found {
return false, nil
}

// Create certs directory in build context
certsDir := filepath.Join(instanceDir, "certs")
if err := os.MkdirAll(certsDir, 0755); err != nil {
return false, fmt.Errorf("failed to create certs directory: %w", err)
}

certPath := filepath.Join(certsDir, "system-ca.crt")
if err := os.WriteFile(certPath, certContent, 0644); err != nil {
return false, fmt.Errorf("failed to write system CA certificates: %w", err)
}

return true, nil
}

// prepareFeatures downloads, orders, and merges options for devcontainer features declared in params.
// Feature directories are written to instanceDir/features/{dirName}/.
// Returns nil if no features are configured.
Expand Down Expand Up @@ -474,9 +558,17 @@ func (p *podmanRuntime) Create(ctx context.Context, params runtime.CreateParams)
}
}

// Copy system CA certificates to build context for enterprise proxy support
// This must happen before Containerfile generation so we know whether to include COPY instructions
certsCopied, err := p.copySystemCACertificates(instanceDir, systemCACertPaths)
if err != nil {
stepLogger.Fail(err)
return runtime.RuntimeInfo{}, err
}

// Create Containerfile
stepLogger.Start("Generating Containerfile", "Containerfile generated")
if err := p.createContainerfile(instanceDir, imageConfig, agentConfig, params.AgentSettings, featureInfos); err != nil {
if err := p.createContainerfile(instanceDir, imageConfig, agentConfig, params.AgentSettings, featureInfos, certsCopied); err != nil {
stepLogger.Fail(err)
return runtime.RuntimeInfo{}, err
}
Expand Down Expand Up @@ -517,6 +609,7 @@ func (p *podmanRuntime) Create(ctx context.Context, params runtime.CreateParams)
AgentUID: p.system.Getuid(),
BaseImageRegistry: constants.BaseImageRegistry,
BaseImageVersion: imageConfig.Version,
WorkspaceImage: imageName, // Use the built workspace image with nftables pre-installed
SourcePath: params.SourcePath,
ProjectID: params.ProjectID,
Agent: params.Agent,
Expand Down
Loading