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 .agents/skills/add-cli-flag/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The existing flags are the canonical reference:
- `--agent` / `--inference` — enum flags backed by `ValueEnum`, gated by a compatibility check in `run()`
- `--endpoint` — `Option<String>` that overrides a provider URL, validated early in `run()`, flows into `resolve_base_url()` and `stage_agent_settings()`
- `--model` — `Option<String>` threaded through `stage_agent_settings()` and `agent.env_vars()`
- `--ssl-certs` — `Option<String>` with `num_args = 0..=1` and `default_missing_value = ""` (optional value flag); converted to `Option<Option<PathBuf>>` before being passed to `run()`

## Step 1 — declare the argument in the `Cli` struct (`src/main.rs`)

Expand Down Expand Up @@ -46,6 +47,7 @@ Then extend the `run()` signature:
fn run(
...
my_flag: Option<&str>, // add here
ssl_certs: Option<Option<PathBuf>>,
runner: &dyn build::Runner,
) -> Result<(), Box<dyn std::error::Error>> {
```
Expand Down Expand Up @@ -108,6 +110,7 @@ fn run_with_my_flag_succeeds() {
None, // endpoint
None, // model
Some("my-value"), // my_flag
None, // ssl_certs
&FakeRunner(0),
);
assert!(result.is_ok(), "expected Ok, got {result:?}");
Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,68 @@ Use `-v` (info) or `-vv` (debug) to increase log verbosity — useful for tracin
openshell-image-builder -v myimage:latest
```

## Enterprise environments

### Corporate proxy support (`--ssl-certs`)

In environments where outbound HTTPS traffic is intercepted by a corporate proxy (e.g. Netskope, Zscaler, or a custom MITM proxy), `dnf install` and `apt-get install` fail during the build because the proxy presents a self-signed or corporate-issued certificate that the container doesn't trust.

Use `--ssl-certs` to copy a CA bundle into the build context and install it in the image. The certificate is trusted both **during the build** (so package installation succeeds) and **at runtime** (so the agent can reach its LLM backend through the same proxy).

**Auto-discover** — the tool searches for a CA bundle in common system locations and uses the first one it finds. Use `--ssl-certs=` (with a trailing `=` and no value) so that the image tag is not mistaken for the certificate path:

```sh
openshell-image-builder --ssl-certs= myimage:latest
```

Paths searched, in order:

| Distribution | Path |
| ------------ | ---- |
| 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 | `/etc/ssl/certs/ca-bundle.crt` |
| Alpine | `/etc/ssl/cert.pem` |
| Arch | `/etc/ca-certificates/extracted/tls-ca-bundle.pem` |

If none of the above paths exist, the build proceeds without adding any certificates.

**Explicit file** — point directly to a specific CA bundle. The build fails immediately if the file does not exist:

```sh
openshell-image-builder --ssl-certs /etc/pki/tls/certs/ca-bundle.crt myimage:latest
```

#### How it works

The CA bundle is copied into the build context and installed in the image's system trust store during the `system` stage, before any packages are installed:

- **Fedora / UBI / Hummingbird** — copied to `/etc/pki/ca-trust/source/anchors/system-ca.crt`, then `update-ca-trust` is run before `dnf install`.
- **Ubuntu** — copied to `/usr/local/share/ca-certificates/system-ca.crt`, then `update-ca-certificates` is run after `apt-get install` (Ubuntu mirrors use HTTP, so the cert is not needed for package installation itself, but is available to the agent at runtime).

Because the `final` image stage inherits the full filesystem from `system`, the CA bundle and updated trust database are present in the running sandbox.

#### Example — agent build behind a corporate proxy

```sh
# Auto-discover the host CA bundle and build with Claude Code
# (--ssl-certs is followed by another --flag, so no trailing = needed)
openshell-image-builder \
--ssl-certs \
--agent claude \
--inference anthropic \
myimage:latest

# Or point to a specific bundle
openshell-image-builder \
--ssl-certs /usr/local/share/ca-certificates/my-corp-ca.crt \
--agent claude \
--inference anthropic \
myimage:latest
```

## Installing an agent

Pass `--agent` to install an agent into the image.
Expand Down Expand Up @@ -500,6 +562,7 @@ openshell-image-builder [OPTIONS] <TAG>
| `--inference <INFERENCE>` | Inference server the agent will connect to (`anthropic`, `vertexai`, `ollama`, `openai`) |
| `--endpoint <URL>` | Override the inference provider's default endpoint URL (see [Custom endpoint](#custom-endpoint---endpoint)) |
| `--model <MODEL>` | Default model for the agent to use (see [Default model](#default-model---model)) |
| `--ssl-certs=[FILE]` | Install system CA certificates in the image (see [Corporate proxy support](#corporate-proxy-support---ssl-certs)). `--ssl-certs=` auto-discovers from common system paths; `--ssl-certs /path/to/bundle.crt` uses that specific file (fails if not found). |
| `-v` / `-vv` | Increase log verbosity (info / debug) |

## Examples
Expand Down
179 changes: 179 additions & 0 deletions src/certs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Copyright (C) 2026 Red Hat, Inc.
//
// 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
//
// http://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.
//
// SPDX-License-Identifier: Apache-2.0

use std::io;
use std::path::Path;

/// Ordered list of common CA bundle paths across Linux distributions.
pub const SYSTEM_CA_CERT_PATHS: &[&str] = &[
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
"/etc/ssl/ca-bundle.pem",
"/etc/ssl/certs/ca-bundle.crt",
"/etc/ssl/cert.pem",
"/etc/ca-certificates/extracted/tls-ca-bundle.pem",
];

/// Tries each path in order; returns the content of the first non-empty regular file found.
pub fn find_system_ca_certificates(cert_paths: &[&str]) -> Option<Vec<u8>> {
for path in cert_paths {
let p = Path::new(path);
if !p.is_file() {
continue;
}
if let Ok(content) = std::fs::read(p)
&& !content.is_empty()
{
return Some(content);
}
}
None
}

fn write_cert_to_context(context_dir: &Path, content: &[u8]) -> io::Result<()> {
let certs_dir = context_dir.join("certs");
std::fs::create_dir_all(&certs_dir)?;
std::fs::write(certs_dir.join("system-ca.crt"), content)
}

/// Auto-discover mode: copies the first found bundle to `<context_dir>/certs/system-ca.crt`.
/// Returns `true` if a cert was found and copied, `false` if none found.
pub fn copy_from_paths(context_dir: &Path, cert_paths: &[&str]) -> io::Result<bool> {
match find_system_ca_certificates(cert_paths) {
None => Ok(false),
Some(content) => {
write_cert_to_context(context_dir, &content)?;
Ok(true)
}
}
}

/// Specific-file mode: reads from `path` and copies to `<context_dir>/certs/system-ca.crt`.
/// Returns an error if the file doesn't exist or can't be read.
pub fn copy_from_file(context_dir: &Path, path: &Path) -> io::Result<()> {
let content = std::fs::read(path)?;
write_cert_to_context(context_dir, &content)
Comment thread
feloy marked this conversation as resolved.
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn find_returns_none_for_empty_paths() {
assert!(find_system_ca_certificates(&[]).is_none());
}

#[test]
fn find_reads_first_existing_path() {
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("bundle.crt");
std::fs::write(&cert_path, b"CERT_DATA").unwrap();
let path_str = cert_path.to_string_lossy().into_owned();
let result = find_system_ca_certificates(&[path_str.as_str()]);
assert_eq!(result, Some(b"CERT_DATA".to_vec()));
}

#[test]
fn find_skips_directories() {
let dir = tempfile::tempdir().unwrap();
let subdir = dir.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
let cert = dir.path().join("bundle.crt");
std::fs::write(&cert, b"REAL_CERT").unwrap();
let dir_str = subdir.to_string_lossy().into_owned();
let cert_str = cert.to_string_lossy().into_owned();
let result = find_system_ca_certificates(&[dir_str.as_str(), cert_str.as_str()]);
assert_eq!(result, Some(b"REAL_CERT".to_vec()));
}

#[test]
fn find_skips_empty_files() {
let dir = tempfile::tempdir().unwrap();
let empty = dir.path().join("empty.crt");
let real = dir.path().join("real.crt");
std::fs::write(&empty, b"").unwrap();
std::fs::write(&real, b"REAL_CERT").unwrap();
let empty_str = empty.to_string_lossy().into_owned();
let real_str = real.to_string_lossy().into_owned();
let result = find_system_ca_certificates(&[empty_str.as_str(), real_str.as_str()]);
assert_eq!(result, Some(b"REAL_CERT".to_vec()));
}

#[test]
fn find_falls_through_to_second_path_when_first_missing() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.crt");
std::fs::write(&real, b"SECOND_CERT").unwrap();
let real_str = real.to_string_lossy().into_owned();
let result = find_system_ca_certificates(&["/nonexistent/path.crt", real_str.as_str()]);
assert_eq!(result, Some(b"SECOND_CERT".to_vec()));
}

#[test]
fn copy_from_paths_returns_false_when_no_certs_found() {
let ctx = tempfile::tempdir().unwrap();
let copied = copy_from_paths(ctx.path(), &[]).unwrap();
assert!(!copied);
assert!(!ctx.path().join("certs").exists());
}

#[test]
fn copy_from_paths_creates_certs_dir_and_file() {
let dir = tempfile::tempdir().unwrap();
let cert = dir.path().join("bundle.crt");
std::fs::write(&cert, b"MY_CERT").unwrap();
let cert_str = cert.to_string_lossy().into_owned();

let ctx = tempfile::tempdir().unwrap();
copy_from_paths(ctx.path(), &[cert_str.as_str()]).unwrap();

assert!(ctx.path().join("certs").join("system-ca.crt").exists());
}

#[test]
fn copy_from_paths_returns_true_when_cert_found() {
let dir = tempfile::tempdir().unwrap();
let cert = dir.path().join("bundle.crt");
std::fs::write(&cert, b"MY_CERT").unwrap();
let cert_str = cert.to_string_lossy().into_owned();

let ctx = tempfile::tempdir().unwrap();
let copied = copy_from_paths(ctx.path(), &[cert_str.as_str()]).unwrap();
assert!(copied);
}

#[test]
fn copy_from_file_writes_content_correctly() {
let dir = tempfile::tempdir().unwrap();
let cert = dir.path().join("bundle.crt");
std::fs::write(&cert, b"CUSTOM_CERT").unwrap();

let ctx = tempfile::tempdir().unwrap();
copy_from_file(ctx.path(), &cert).unwrap();

let written = std::fs::read(ctx.path().join("certs").join("system-ca.crt")).unwrap();
assert_eq!(written, b"CUSTOM_CERT");
}

#[test]
fn copy_from_file_errors_when_file_missing() {
let ctx = tempfile::tempdir().unwrap();
let result = copy_from_file(ctx.path(), Path::new("/nonexistent/bundle.crt"));
assert!(result.is_err());
}
}
Loading