Inject the corporate CA certificate into containers
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped

Behind a TLS-terminating corporate proxy every HTTPS call inside a container
fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API
requests. There was no mechanism at all: installing the certificate by hand
inside a container is lost on Reset and had to be repeated per project.

A global CA path in AppSettings with a per-project override on Project, taking
either a single certificate file or a directory. It is bind-mounted read-only
at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by
entrypoint.sh on every start, so it survives recreation, migration and Reset.

Four things this gets right that are easy to get wrong:

* update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely
  copied in is ignored in silence. Certificates are renamed, by
  container_cert_name() in Rust and a mirrored few lines of shell.
* The system store only serves curl/git/apt. Node — and so Claude Code itself —
  needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and
  Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil
  (libnss3-tools, added to the image).
* Those vars are set from Rust at creation, never exported by the entrypoint —
  a terminal is a docker exec and sees nothing the entrypoint exported. They are
  emitted empty when no CA is configured, since docker commit bakes env into the
  snapshot image.
* triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so
  a CA rotated in at the same location still forces a recreation.

Verified end to end against a real container and a self-signed CA: curl, node,
python and git all complete a TLS handshake against a server signed by it and
all three fail in the same container without it; the env vars are visible from
a docker exec session; the store is cleaned when the setting is cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
This commit is contained in:
Claude
2026-08-10 10:40:21 -07:00
parent 584fcdd837
commit 7a5823cb2b
19 changed files with 1367 additions and 5 deletions
@@ -78,6 +78,7 @@ pub(crate) async fn create_container_for_project(
settings.timezone.as_deref(),
settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(),
settings.ca_cert_path.as_deref(),
settings.default_git_user_name.as_deref(),
settings.default_git_user_email.as_deref(),
)
@@ -406,6 +407,7 @@ pub async fn start_project_container(
settings.timezone.as_deref(),
settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(),
settings.ca_cert_path.as_deref(),
settings.default_git_user_name.as_deref(),
settings.default_git_user_email.as_deref(),
).await.unwrap_or(false);
@@ -155,6 +155,78 @@ pub async fn detect_aws_config() -> Result<Option<String>, String> {
Ok(None)
}
/// What the UI shows next to a corporate CA certificate path.
///
/// Errors are returned *inside* the payload rather than as `Err` so the field
/// can render its own inline message while the user is still typing — a toast
/// per keystroke would be unusable. The same check runs again, as a hard error,
/// when the container is created.
#[derive(Debug, serde::Serialize)]
pub struct CaCertInfo {
pub exists: bool,
pub is_directory: bool,
/// How many certificate files were found.
pub cert_count: usize,
/// The names they will be installed as inside the container. Surfacing
/// these makes the silent `.pem` → `.crt` rename visible, which is the one
/// step users most often do by hand and get wrong.
pub installed_names: Vec<String>,
/// Why the path is unusable, if it is.
pub error: Option<String>,
}
#[tauri::command]
pub async fn inspect_ca_cert_path(path: String) -> Result<CaCertInfo, String> {
use crate::docker::ca_certs;
let trimmed = path.trim();
if trimmed.is_empty() {
return Ok(CaCertInfo {
exists: false,
is_directory: false,
cert_count: 0,
installed_names: Vec::new(),
error: None,
});
}
let p = std::path::Path::new(trimmed);
let exists = p.exists();
let is_directory = p.is_dir();
match ca_certs::resolve(Some(trimmed)) {
Ok(Some(resolved)) => Ok(CaCertInfo {
exists,
is_directory,
cert_count: resolved.cert_files.len(),
installed_names: resolved
.cert_files
.iter()
.map(|f| {
ca_certs::container_cert_name(
&f.file_name().unwrap_or_default().to_string_lossy(),
)
})
.collect(),
error: None,
}),
Ok(None) => Ok(CaCertInfo {
exists,
is_directory,
cert_count: 0,
installed_names: Vec::new(),
error: None,
}),
Err(e) => Ok(CaCertInfo {
exists,
is_directory,
cert_count: 0,
installed_names: Vec::new(),
error: Some(e),
}),
}
}
#[tauri::command]
pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
let mut profiles = Vec::new();
+580
View File
@@ -0,0 +1,580 @@
//! Corporate CA certificate injection.
//!
//! Users behind a TLS-terminating corporate proxy need their organisation's
//! root CA inside every container, or **every** HTTPS call fails — npm, pip,
//! git, curl, the Playwright browser, and Claude Code's own API calls.
//!
//! The mechanism follows the SSH/AWS host-mount pattern in [`super::container`]:
//! a host path is bind-mounted **read-only** into the container and
//! `entrypoint.sh` applies it on every start. That is what makes it durable
//! across container recreation, base-image migration and Reset — a certificate
//! installed by hand inside a running container is lost the first time any of
//! those happen.
//!
//! ## Two things that are easy to get wrong
//!
//! 1. **`update-ca-certificates` only reads `*.crt`.** It globs
//! `/usr/local/share/ca-certificates/*.crt` case-sensitively, so a `.pem`
//! (the far more common export format) that is merely *copied* in is
//! silently ignored — no warning, no error, just a container that still
//! cannot speak HTTPS. Certificates must be **renamed**, which is what
//! [`container_cert_name`] does.
//!
//! 2. **The system trust store is not enough.** Only curl/git/apt read it.
//! Node — and therefore Claude Code itself — needs `NODE_EXTRA_CA_CERTS`,
//! Python/requests need `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`, and
//! Chrome/Chromium read neither: they have their own NSS database at
//! `~/.pki/nssdb`, seeded by `certutil` in the entrypoint.
//!
//! ## Why the env vars are set from Rust and not exported by the entrypoint
//!
//! An `export` in `entrypoint.sh` reaches only the entrypoint's own children.
//! Every terminal session is a separate `docker exec`, which inherits the
//! *container's* configured env and sees nothing the entrypoint exported —
//! the same lesson that forced `$BROWSER` to become an image-level `ENV` for
//! the URL relay shim. Since the bundle path written by
//! `update-ca-certificates` is deterministic ([`CA_BUNDLE_PATH`]), Rust can set
//! all three vars at container creation, where `docker exec` will see them.
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
/// Where the host's CA material is bind-mounted, read-only. Mirrors
/// `/tmp/.host-ssh` and `/tmp/.host-aws`.
///
/// A *directory* on the host is mounted here as-is. A single *file* is mounted
/// at `<CA_MOUNT_DIR>/<normalised name>` — Docker creates the parent — so the
/// entrypoint only ever has to deal with a directory, and the certificate keeps
/// a recognisable name instead of becoming the literal path `.host-ca`.
pub const CA_MOUNT_DIR: &str = "/tmp/.host-ca";
/// The concatenated PEM bundle `update-ca-certificates` writes on
/// Debian/Ubuntu. Deterministic, which is what lets the env vars below be set
/// at container-creation time, before the entrypoint has run.
pub const CA_BUNDLE_PATH: &str = "/etc/ssl/certs/ca-certificates.crt";
/// Consulted by Node — and therefore by Claude Code itself, which is the whole
/// reason this feature exists.
pub const NODE_EXTRA_CA_CERTS: &str = "NODE_EXTRA_CA_CERTS";
/// Consulted by `requests` (and so by pip's vendored copy).
pub const REQUESTS_CA_BUNDLE: &str = "REQUESTS_CA_BUNDLE";
/// Consulted by OpenSSL, and so by Python's `ssl` module.
pub const SSL_CERT_FILE: &str = "SSL_CERT_FILE";
/// Every env var this module owns, in a fixed order.
///
/// Also the list that must be *cleared* when no CA is configured: `docker
/// commit` bakes a container's env into the project's snapshot image, and
/// create-time env replaces image `ENV` per key — so without an explicit empty
/// value, removing the setting would leave the vars live in every future
/// container. Empty is safe for all three (verified on Ubuntu 24.04: curl,
/// `openssl s_client` and Python's `ssl` all behave exactly as they do with the
/// variable unset).
pub const CA_ENV_KEYS: &[&str] = &[NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, SSL_CERT_FILE];
/// Extensions treated as certificates when the configured path is a directory.
/// Matched case-insensitively. DER is deliberately absent — the system store
/// and every consumer here want PEM.
const CERT_EXTENSIONS: &[&str] = &["crt", "pem", "cer", "cert", "ca-bundle"];
/// A configured CA path that has been checked and resolved into everything the
/// container creation path needs.
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedCa {
/// The host path, as configured.
pub host_path: String,
/// Whether the host path is a directory (as opposed to a single file).
pub is_dir: bool,
/// The bind-mount target inside the container.
pub mount_target: String,
/// The certificate files found, sorted.
pub cert_files: Vec<PathBuf>,
}
fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
format!("{:x}", hasher.finalize())
}
/// The file name a certificate is installed as under
/// `/usr/local/share/ca-certificates/`.
///
/// `update-ca-certificates` globs `*.crt` **case-sensitively**, so `.pem`,
/// `.cer`, `.CRT` and extension-less files all have to end up as a lowercase
/// `.crt` or they are ignored without a word. Characters outside
/// `[A-Za-z0-9._-]` are replaced so that whitespace cannot break the shell
/// loops that walk the store, and leading dots are stripped so a hidden file
/// does not stay hidden.
///
/// `entrypoint.sh` reimplements exactly this in a few lines of shell (it has to
/// rename the files inside the container); the two must agree, which is what
/// the unit tests below pin down.
pub fn container_cert_name(file_name: &str) -> String {
let sanitized: String = file_name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
let sanitized = sanitized.trim_start_matches('.');
// Strip one trailing extension, whatever it is, then force `.crt`. A name
// with no dot keeps its whole self as the stem.
let stem = match sanitized.rfind('.') {
Some(i) => &sanitized[..i],
None => sanitized,
};
let stem = if stem.is_empty() { "corporate-ca" } else { stem };
format!("{}.crt", stem)
}
/// Whether a directory entry looks like a certificate worth installing.
fn is_cert_file(path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return false;
};
let ext = ext.to_ascii_lowercase();
CERT_EXTENSIONS.contains(&ext.as_str())
}
/// The certificate files a configured path contributes.
///
/// A file is taken at face value — the user pointed at it explicitly, so its
/// extension is not second-guessed. A directory is scanned one level deep
/// (matching the entrypoint's `find -maxdepth 1`) and filtered by extension,
/// so an `openssl.cnf` or a README sitting next to the certs is skipped.
/// The result is sorted, so the fingerprint is stable across filesystem
/// enumeration order.
pub fn collect_cert_files(path: &Path) -> Vec<PathBuf> {
if path.is_file() {
return vec![path.to_path_buf()];
}
if !path.is_dir() {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir(path) else {
return Vec::new();
};
let mut files: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && is_cert_file(p))
.collect();
files.sort();
files
}
/// Resolve the configured CA path, or explain why it cannot be used.
///
/// `Ok(None)` means "no CA configured", which is the overwhelmingly common
/// case and must stay free. An `Err` aborts the container start: behind a
/// TLS-intercepting proxy a container without the CA is broken in a dozen
/// confusing ways, so naming the bad path once is far kinder than letting npm,
/// pip and Claude Code each fail their own way.
pub fn resolve(path: Option<&str>) -> Result<Option<ResolvedCa>, String> {
let Some(raw) = path.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(None);
};
let root = Path::new(raw);
if !root.exists() {
return Err(format!(
"Corporate CA certificate path '{}' does not exist. Update it in \
Settings → Certificates, or clear this project's override in \
Project Home → Config → Access.",
raw
));
}
let is_dir = root.is_dir();
if !is_dir && !root.is_file() {
return Err(format!(
"Corporate CA certificate path '{}' is neither a file nor a directory.",
raw
));
}
let cert_files = collect_cert_files(root);
if cert_files.is_empty() {
return Err(format!(
"Corporate CA certificate directory '{}' contains no certificate files \
(looked for {} one level deep).",
raw,
CERT_EXTENSIONS
.iter()
.map(|e| format!(".{}", e))
.collect::<Vec<_>>()
.join(", ")
));
}
let mount_target = if is_dir {
CA_MOUNT_DIR.to_string()
} else {
let name = root
.file_name()
.map(|n| container_cert_name(&n.to_string_lossy()))
.unwrap_or_else(|| "corporate-ca.crt".to_string());
format!("{}/{}", CA_MOUNT_DIR, name)
};
Ok(Some(ResolvedCa {
host_path: raw.to_string(),
is_dir,
mount_target,
cert_files,
}))
}
/// Fingerprint of the CA configuration, for the `triple-c.ca-fingerprint`
/// label.
///
/// `container_needs_recreation` is label-based and never diffs env or mounts,
/// so without this, changing the CA path would silently do nothing until some
/// unrelated setting forced a rebuild.
///
/// It covers **both** the resolved path *and the bytes of every certificate*,
/// because replacing a rotated CA at the same path is at least as common as
/// moving it — and the container's copy is made once, at start, so nothing else
/// would notice.
///
/// Never returns an error: a path that has gone missing hashes differently from
/// one that is present, which is exactly the "something changed, recreate"
/// signal wanted here. Reporting the problem is [`resolve`]'s job.
pub fn compute_ca_fingerprint(path: Option<&str>) -> String {
let Some(raw) = path.map(str::trim).filter(|s| !s.is_empty()) else {
return String::new();
};
let mut parts: Vec<String> = vec![raw.to_string()];
let root = Path::new(raw);
if !root.exists() {
parts.push("<missing>".to_string());
} else {
for file in collect_cert_files(root) {
let name = file
.file_name()
.map(|n| container_cert_name(&n.to_string_lossy()))
.unwrap_or_default();
let digest = match std::fs::read(&file) {
Ok(bytes) => {
let mut hasher = Sha256::new();
hasher.update(&bytes);
format!("{:x}", hasher.finalize())
}
Err(_) => "<unreadable>".to_string(),
};
parts.push(format!("{}:{}", name, digest));
}
}
sha256_hex(&parts.join("|"))
}
/// The env vars to set on the container.
///
/// Always returns all of [`CA_ENV_KEYS`]: pointing at the bundle when a CA is
/// configured, empty when it is not. The empty case is not cosmetic — see the
/// note on [`CA_ENV_KEYS`].
pub fn ca_env_vars(resolved: Option<&ResolvedCa>) -> Vec<(&'static str, String)> {
let value = if resolved.is_some() { CA_BUNDLE_PATH } else { "" };
CA_ENV_KEYS
.iter()
.map(|key| (*key, value.to_string()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// A scratch directory that cleans itself up. `tempfile` is not a
/// dependency of this crate and this is the only test that needs one.
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let mut p = std::env::temp_dir();
p.push(format!(
"triple-c-ca-test-{}-{}-{:?}",
tag,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&p).unwrap();
TempDir(p)
}
fn path(&self) -> &Path {
&self.0
}
fn write(&self, name: &str, contents: &str) -> PathBuf {
let p = self.0.join(name);
fs::write(&p, contents).unwrap();
p
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
// ── container_cert_name ────────────────────────────────────────────────
#[test]
fn a_pem_is_renamed_to_crt_not_merely_copied() {
// The whole point: update-ca-certificates globs *.crt and would
// silently ignore corp-root.pem.
assert_eq!(container_cert_name("corp-root.pem"), "corp-root.crt");
}
#[test]
fn a_crt_keeps_its_name() {
assert_eq!(container_cert_name("corp-root.crt"), "corp-root.crt");
}
#[test]
fn other_certificate_extensions_are_renamed_too() {
assert_eq!(container_cert_name("zscaler.cer"), "zscaler.crt");
assert_eq!(container_cert_name("zscaler.cert"), "zscaler.crt");
assert_eq!(container_cert_name("bundle.ca-bundle"), "bundle.crt");
}
#[test]
fn an_uppercase_extension_is_lowercased() {
// `find -name '*.crt'` is case-sensitive, so CA.CRT would be ignored.
assert_eq!(container_cert_name("CA.CRT"), "CA.crt");
assert_eq!(container_cert_name("CA.PEM"), "CA.crt");
}
#[test]
fn a_name_without_an_extension_gains_one() {
assert_eq!(container_cert_name("corporate-root"), "corporate-root.crt");
}
#[test]
fn only_the_last_extension_is_replaced() {
assert_eq!(container_cert_name("corp.root.ca.pem"), "corp.root.ca.crt");
}
#[test]
fn unsafe_characters_are_replaced() {
assert_eq!(
container_cert_name("Corp Root CA (2026).pem"),
"Corp_Root_CA__2026_.crt"
);
assert_eq!(container_cert_name("a/b.pem"), "a_b.crt");
}
#[test]
fn leading_dots_are_stripped_so_the_file_is_not_hidden() {
assert_eq!(container_cert_name(".hidden.pem"), "hidden.crt");
}
#[test]
fn a_degenerate_name_still_produces_a_usable_file() {
assert_eq!(container_cert_name(".pem"), "pem.crt");
assert_eq!(container_cert_name(""), "corporate-ca.crt");
assert_eq!(container_cert_name("..."), "corporate-ca.crt");
}
#[test]
fn every_produced_name_ends_in_lowercase_crt() {
for input in [
"a.pem", "b.CRT", "c", ".d.pem", "", "e f.cer", "...", "ç.pem",
] {
let out = container_cert_name(input);
assert!(
out.ends_with(".crt"),
"{:?} produced {:?}, which update-ca-certificates would ignore",
input,
out
);
assert!(
out.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'),
"{:?} produced {:?}, which is not shell-safe",
input,
out
);
}
}
// ── fingerprint ────────────────────────────────────────────────────────
#[test]
fn no_configured_path_fingerprints_as_empty() {
assert_eq!(compute_ca_fingerprint(None), "");
assert_eq!(compute_ca_fingerprint(Some("")), "");
assert_eq!(compute_ca_fingerprint(Some(" ")), "");
}
#[test]
fn changing_the_path_changes_the_fingerprint() {
let a = TempDir::new("path-a");
let b = TempDir::new("path-b");
// Identical *content* in both, so only the path differs.
a.write("corp.pem", "CERT-BODY");
b.write("corp.pem", "CERT-BODY");
let fp_a = compute_ca_fingerprint(Some(a.path().to_str().unwrap()));
let fp_b = compute_ca_fingerprint(Some(b.path().to_str().unwrap()));
assert_ne!(fp_a, "");
assert_ne!(
fp_a, fp_b,
"two different paths must not share a fingerprint"
);
}
#[test]
fn changing_the_certificate_content_at_the_same_path_changes_the_fingerprint() {
// The case a path-only fingerprint would miss: the corporate CA is
// rotated and the new one dropped in at exactly the same location.
let dir = TempDir::new("rotate");
dir.write("corp.pem", "OLD-CERT");
let before = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
dir.write("corp.pem", "NEW-CERT");
let after = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
assert_ne!(
before, after,
"replacing the certificate at the same path must force a recreation"
);
}
#[test]
fn adding_or_removing_a_certificate_changes_the_fingerprint() {
let dir = TempDir::new("add");
dir.write("one.pem", "A");
let one = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
dir.write("two.pem", "B");
let two = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
assert_ne!(one, two);
fs::remove_file(dir.path().join("two.pem")).unwrap();
assert_eq!(compute_ca_fingerprint(Some(dir.path().to_str().unwrap())), one);
}
#[test]
fn an_unchanged_directory_fingerprints_identically() {
let dir = TempDir::new("stable");
dir.write("corp.pem", "SAME");
let a = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
let b = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
assert_eq!(a, b, "the fingerprint must not churn on repeated reads");
}
#[test]
fn a_missing_path_fingerprints_differently_from_a_present_one() {
let dir = TempDir::new("missing");
let present = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
let missing =
compute_ca_fingerprint(Some(&format!("{}-gone", dir.path().to_str().unwrap())));
assert_ne!(present, missing);
assert_ne!(missing, "");
}
#[test]
fn non_certificate_files_in_the_directory_are_ignored() {
let dir = TempDir::new("noise");
dir.write("corp.pem", "CERT");
let before = compute_ca_fingerprint(Some(dir.path().to_str().unwrap()));
dir.write("README.md", "hello");
dir.write("openssl.cnf", "[req]");
assert_eq!(
compute_ca_fingerprint(Some(dir.path().to_str().unwrap())),
before
);
}
// ── resolve ────────────────────────────────────────────────────────────
#[test]
fn no_path_resolves_to_nothing() {
assert_eq!(resolve(None).unwrap(), None);
assert_eq!(resolve(Some(" ")).unwrap(), None);
}
#[test]
fn a_missing_path_is_an_actionable_error() {
let err = resolve(Some("/definitely/not/here/corp.pem")).unwrap_err();
assert!(err.contains("/definitely/not/here/corp.pem"), "{}", err);
assert!(err.contains("Settings"), "{}", err);
}
#[test]
fn an_empty_directory_is_an_actionable_error() {
let dir = TempDir::new("empty");
let err = resolve(Some(dir.path().to_str().unwrap())).unwrap_err();
assert!(err.contains("no certificate files"), "{}", err);
assert!(err.contains(".pem"), "{}", err);
}
#[test]
fn a_directory_mounts_at_the_shared_mount_point() {
let dir = TempDir::new("dir");
dir.write("corp.pem", "CERT");
let resolved = resolve(Some(dir.path().to_str().unwrap())).unwrap().unwrap();
assert!(resolved.is_dir);
assert_eq!(resolved.mount_target, CA_MOUNT_DIR);
assert_eq!(resolved.cert_files.len(), 1);
}
#[test]
fn a_single_file_mounts_under_the_mount_point_with_a_crt_name() {
// Mounting a file *at* /tmp/.host-ca would leave the entrypoint with no
// name to work from, and would make the mount point a file rather than
// the directory the entrypoint expects.
let dir = TempDir::new("file");
let file = dir.write("corp root.pem", "CERT");
let resolved = resolve(Some(file.to_str().unwrap())).unwrap().unwrap();
assert!(!resolved.is_dir);
assert_eq!(
resolved.mount_target,
format!("{}/corp_root.crt", CA_MOUNT_DIR)
);
}
#[test]
fn a_file_is_accepted_whatever_its_extension() {
// The user pointed at it explicitly; don't second-guess.
let dir = TempDir::new("odd-ext");
let file = dir.write("corp.txt", "CERT");
let resolved = resolve(Some(file.to_str().unwrap())).unwrap().unwrap();
assert_eq!(resolved.cert_files, vec![file]);
}
// ── env vars ───────────────────────────────────────────────────────────
#[test]
fn configured_ca_points_every_consumer_at_the_bundle() {
let dir = TempDir::new("env");
dir.write("corp.pem", "CERT");
let resolved = resolve(Some(dir.path().to_str().unwrap())).unwrap();
let vars = ca_env_vars(resolved.as_ref());
assert_eq!(
vars,
vec![
(NODE_EXTRA_CA_CERTS, CA_BUNDLE_PATH.to_string()),
(REQUESTS_CA_BUNDLE, CA_BUNDLE_PATH.to_string()),
(SSL_CERT_FILE, CA_BUNDLE_PATH.to_string()),
]
);
}
#[test]
fn no_ca_clears_every_var_rather_than_omitting_it() {
// Omitting them would let a value baked into the project's snapshot
// image survive the setting being turned off.
let vars = ca_env_vars(None);
assert_eq!(vars.len(), CA_ENV_KEYS.len());
assert!(vars.iter().all(|(_, v)| v.is_empty()));
}
}
+86 -1
View File
@@ -7,6 +7,7 @@ use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBi
use std::collections::HashMap;
use sha2::{Sha256, Digest};
use super::ca_certs;
use super::client::get_docker;
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
@@ -792,6 +793,7 @@ pub async fn create_container(
timezone: Option<&str>,
global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>,
default_ca_cert_path: Option<&str>,
default_git_user_name: Option<&str>,
default_git_user_email: Option<&str>,
) -> Result<String, String> {
@@ -1029,6 +1031,34 @@ pub async fn create_container(
);
}
// ── Corporate CA certificates ───────────────────────────────────────────
// Resolved here (rather than down with the mounts) so the env vars land
// *before* the neutralization pass below and are seen as already-set.
//
// A bad path is a hard error, not a warning: behind a TLS-terminating
// proxy a container without the CA fails every HTTPS call — npm, pip, git,
// and Claude Code's own API requests — each in its own confusing way. One
// message naming the path is far kinder.
//
// The values are set here rather than exported by the entrypoint because a
// terminal session is a `docker exec`, which sees the container's
// configured env and nothing the entrypoint exported. Same lesson as
// `$BROWSER` and the URL relay shim.
let effective_ca_path =
resolve_with_global(project.ca_cert_path.as_deref(), default_ca_cert_path);
let resolved_ca = ca_certs::resolve(effective_ca_path)?;
if let Some(ref ca) = resolved_ca {
log::info!(
"Mounting {} corporate CA certificate(s) from {} into project {}",
ca.cert_files.len(),
ca.host_path,
project.id
);
}
for (key, value) in ca_certs::ca_env_vars(resolved_ca.as_ref()) {
env_vars.push(format!("{}={}", key, value));
}
// ── Neutralize stale backend auth env vars ──────────────────────────────
// When a project switches backends (e.g. Bedrock → Anthropic) the container
// is recreated *from a snapshot image* committed off the previous container,
@@ -1073,11 +1103,19 @@ pub async fn create_container(
// authenticating the container with a credential the user removed.
CLAUDE_OAUTH_TOKEN_ENV,
];
// Same reasoning for the CA vars — `ca_env_vars` already emits them empty
// when no CA is configured, so this list is belt-and-braces for a snapshot
// committed by a build that predates the feature.
let managed_keys: Vec<&str> = MANAGED_AUTH_KEYS
.iter()
.copied()
.chain(ca_certs::CA_ENV_KEYS.iter().copied())
.collect();
let already_set: std::collections::HashSet<String> = env_vars
.iter()
.filter_map(|e| e.split('=').next().map(|k| k.to_string()))
.collect();
for key in MANAGED_AUTH_KEYS {
for key in &managed_keys {
if !already_set.contains(*key) {
env_vars.push(format!("{}=", key));
}
@@ -1209,6 +1247,23 @@ pub async fn create_container(
});
}
// Corporate CA certificates mount (read-only staging; the entrypoint copies
// them into /usr/local/share/ca-certificates with a `.crt` name and runs
// update-ca-certificates). Mirrors /tmp/.host-ssh and /tmp/.host-aws.
//
// A directory mounts at /tmp/.host-ca; a single file mounts at
// /tmp/.host-ca/<name>.crt so the entrypoint always sees a directory and
// the certificate keeps a recognisable name. Docker creates the parent.
if let Some(ref ca) = resolved_ca {
mounts.push(Mount {
target: Some(ca.mount_target.clone()),
source: Some(ca.host_path.clone()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(true),
..Default::default()
});
}
// AWS config mount (read-only)
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
let should_mount_aws = if project.backend == Backend::Bedrock {
@@ -1306,6 +1361,13 @@ pub async fn create_container(
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled));
labels.insert("triple-c.instructions-fingerprint".to_string(),
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
// Written unconditionally, even when empty — `container_needs_recreation`
// is label-based and never diffs env or mounts, so without this a changed
// CA path would silently do nothing until some unrelated setting forced a
// rebuild. The fingerprint covers the certificate *bytes* as well as the
// path, so swapping a rotated CA in at the same location is caught too.
labels.insert("triple-c.ca-fingerprint".to_string(),
ca_certs::compute_ca_fingerprint(effective_ca_path));
labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string());
labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string());
labels.insert("triple-c.git-token-hash".to_string(),
@@ -1911,6 +1973,7 @@ pub async fn container_needs_recreation(
timezone: Option<&str>,
global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>,
default_ca_cert_path: Option<&str>,
default_git_user_name: Option<&str>,
default_git_user_email: Option<&str>,
) -> Result<bool, String> {
@@ -2078,6 +2141,28 @@ pub async fn container_needs_recreation(
return Ok(true);
}
// ── Corporate CA certificates ────────────────────────────────────────
// Both the resolved path and the certificate contents, so replacing a
// rotated CA at the same path recreates the container — the copy inside
// the container is made once, at start, and nothing else would notice.
//
// A container predating this feature has no label, i.e. "", which is also
// what an unconfigured CA fingerprints as — so existing installs are not
// churned until a CA is actually set.
let expected_ca_fp = ca_certs::compute_ca_fingerprint(resolve_with_global(
project.ca_cert_path.as_deref(),
default_ca_cert_path,
));
let container_ca_fp = get_label("triple-c.ca-fingerprint").unwrap_or_default();
if container_ca_fp != expected_ca_fp {
log::info!(
"Corporate CA certificate mismatch (container={:?}, expected={:?})",
container_ca_fp,
expected_ca_fp
);
return Ok(true);
}
// ── Git settings (label-based to avoid stale snapshot env vars) ─────
let expected_git_name = project.git_user_name.as_deref()
.or(default_git_user_name)
+4
View File
@@ -1,3 +1,4 @@
pub mod ca_certs;
pub mod client;
pub mod container;
pub mod image;
@@ -23,3 +24,6 @@ pub use exec::*;
pub use legacy_cleanup::*;
#[allow(unused_imports)]
pub use migration::*;
// Deliberately *not* re-exported flat: `ca_certs::resolve` and
// `ca_certs::CA_MOUNT_DIR` are far clearer than bare `resolve` in a module that
// already re-exports five other namespaces.
+1
View File
@@ -437,6 +437,7 @@ pub fn run() {
commands::settings_commands::update_settings,
commands::settings_commands::pull_image,
commands::settings_commands::detect_aws_config,
commands::settings_commands::inspect_ca_cert_path,
commands::settings_commands::list_aws_profiles,
commands::settings_commands::detect_host_timezone,
// Terminal
+9
View File
@@ -87,6 +87,14 @@ pub struct GlobalOpenAiCompatibleSettings {
pub struct AppSettings {
#[serde(default)]
pub default_ssh_key_path: Option<String>,
/// Path to the organisation's root CA — a single certificate file or a
/// directory of them. Mounted read-only into every container, which then
/// installs it into the system trust store, Node's `NODE_EXTRA_CA_CERTS`,
/// Python's `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE` and Chrome's NSS database.
/// Required when the host sits behind a TLS-terminating corporate proxy.
/// Overridden per project by `Project::ca_cert_path`.
#[serde(default)]
pub ca_cert_path: Option<String>,
#[serde(default)]
pub default_git_user_name: Option<String>,
#[serde(default)]
@@ -197,6 +205,7 @@ impl Default for AppSettings {
fn default() -> Self {
Self {
default_ssh_key_path: None,
ca_cert_path: None,
default_git_user_name: None,
default_git_user_email: None,
docker_socket_path: None,
+8
View File
@@ -166,6 +166,13 @@ pub struct Project {
#[serde(default)]
pub permission_mode: Option<PermissionMode>,
pub ssh_key_path: Option<String>,
/// Per-project override for the corporate CA certificate path (file or
/// directory). Blank falls back to `AppSettings::ca_cert_path`.
///
/// `#[serde(default)]` rather than a required field: every project stored
/// before this existed must keep loading.
#[serde(default)]
pub ca_cert_path: Option<String>,
#[serde(skip_serializing, default)]
pub git_token: Option<String>,
pub git_user_name: Option<String>,
@@ -363,6 +370,7 @@ impl Project {
full_permissions: false,
permission_mode: None,
ssh_key_path: None,
ca_cert_path: None,
git_token: None,
git_user_name: None,
git_user_email: None,
@@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog";
import type { Project } from "../../../../lib/types";
import Button from "../../../ui/Button";
import Field, { ConfigGroup, inputClass } from "../../../ui/Field";
import CaCertPathInput from "../../../settings/CaCertPathInput";
import EnvVarsEditor from "../../EnvVarsEditor";
import PortMappingsEditor from "../../PortMappingsEditor";
@@ -20,12 +21,14 @@ export default function AccessSection({
disabledReason,
}: Props) {
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? "");
const [gitName, setGitName] = useState(project.git_user_name ?? "");
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
const [gitToken, setGitToken] = useState(project.git_token ?? "");
useEffect(() => {
setSshKeyPath(project.ssh_key_path ?? "");
setCaCertPath(project.ca_cert_path ?? "");
setGitName(project.git_user_name ?? "");
setGitEmail(project.git_user_email ?? "");
setGitToken(project.git_token ?? "");
@@ -114,6 +117,24 @@ export default function AccessSection({
)}
</Field>
<Field
label="Corporate CA certificate"
hint="Overrides the global certificate for this project only. A certificate file, or a folder of them, trusted inside the container by curl, git, npm, pip, Chromium and Claude Code."
>
{(id) => (
<CaCertPathInput
id={id}
value={caCertPath}
onChange={setCaCertPath}
onCommit={(value) => save({ ca_cert_path: value.trim() || null })}
disabled={disabled}
placeholder="/etc/ssl/certs/corp-root.pem"
emptyHint="Using the global certificate from Settings → Certificates."
inputClassName={`${inputClass} min-w-0`}
/>
)}
</Field>
<div className="pt-2 border-t border-[var(--border-color)]">
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
Environment variables
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import CaCertPathInput from "./CaCertPathInput";
import type { CaCertInfo } from "../../lib/types";
const inspectCaCertPath = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
inspectCaCertPath: (path: string) => inspectCaCertPath(path),
}));
const openDialog = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: (opts: unknown) => openDialog(opts),
}));
const info = (over: Partial<CaCertInfo> = {}): CaCertInfo => ({
exists: true,
is_directory: false,
cert_count: 1,
installed_names: ["corp-root.crt"],
error: null,
...over,
});
function renderInput(value = "", over: Partial<Parameters<typeof CaCertPathInput>[0]> = {}) {
const onChange = vi.fn();
const onCommit = vi.fn();
const utils = render(
<CaCertPathInput
value={value}
onChange={onChange}
onCommit={onCommit}
inputClassName="input"
{...over}
/>,
);
return { onChange, onCommit, ...utils };
}
describe("CaCertPathInput", () => {
beforeEach(() => {
vi.clearAllMocks();
inspectCaCertPath.mockResolvedValue(info());
});
it("does not inspect anything while the path is empty", async () => {
renderInput("");
await new Promise((r) => setTimeout(r, 350));
expect(inspectCaCertPath).not.toHaveBeenCalled();
});
it("shows the empty hint instead of a status when unset", () => {
renderInput("", { emptyHint: "Using the global certificate." });
expect(screen.getByText("Using the global certificate.")).toBeTruthy();
});
it("reports the certificate count and the names they are installed as", async () => {
// The rename is the whole point: update-ca-certificates ignores a .pem.
inspectCaCertPath.mockResolvedValue(
info({ cert_count: 2, installed_names: ["corp-root.crt", "corp-intermediate.crt"] }),
);
renderInput("/certs");
await waitFor(() => expect(screen.getByText(/Found 2 certificates/)).toBeTruthy());
expect(screen.getByText(/corp-root\.crt, corp-intermediate\.crt/)).toBeTruthy();
});
it("uses the singular for one certificate", async () => {
renderInput("/certs/corp.pem");
await waitFor(() => expect(screen.getByText(/Found 1 certificate$|Found 1 certificate/)).toBeTruthy());
expect(screen.queryByText(/Found 1 certificates/)).toBeNull();
});
it("surfaces an unusable path inline rather than silently accepting it", async () => {
inspectCaCertPath.mockResolvedValue(
info({ exists: false, cert_count: 0, installed_names: [], error: "path does not exist" }),
);
renderInput("/gone");
await waitFor(() => expect(screen.getByText(/path does not exist/)).toBeTruthy());
});
it("commits on blur", () => {
const { onCommit } = renderInput("/certs");
fireEvent.blur(screen.getByRole("textbox"));
expect(onCommit).toHaveBeenCalledWith("/certs");
});
it("offers both a file and a folder picker, because the setting accepts either", async () => {
openDialog.mockResolvedValue("/picked/corp.pem");
const { onChange, onCommit } = renderInput("");
fireEvent.click(screen.getByText("File…"));
await waitFor(() => expect(onCommit).toHaveBeenCalledWith("/picked/corp.pem"));
expect(openDialog).toHaveBeenCalledWith({ directory: false, multiple: false });
openDialog.mockResolvedValue("/picked/certs");
fireEvent.click(screen.getByText("Folder…"));
await waitFor(() => expect(openDialog).toHaveBeenLastCalledWith({ directory: true, multiple: false }));
expect(onChange).toHaveBeenCalledWith("/picked/certs");
});
it("does not commit when the picker is dismissed", async () => {
openDialog.mockResolvedValue(null);
const { onCommit } = renderInput("");
fireEvent.click(screen.getByText("Folder…"));
await new Promise((r) => setTimeout(r, 0));
expect(onCommit).not.toHaveBeenCalled();
});
it("disables the inputs when the container is running", () => {
renderInput("/certs", { disabled: true });
expect((screen.getByRole("textbox") as HTMLInputElement).disabled).toBe(true);
for (const label of ["File…", "Folder…"]) {
expect((screen.getByText(label) as HTMLButtonElement).disabled).toBe(true);
}
});
});
@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import Button from "../ui/Button";
import { inspectCaCertPath } from "../../lib/tauri-commands";
import type { CaCertInfo } from "../../lib/types";
interface Props {
/** Wired to the calling `Field`'s label, where there is one. */
id?: string;
value: string;
onChange: (value: string) => void;
/** Persist the value — called on blur and immediately after a Browse. */
onCommit: (value: string) => void;
disabled?: boolean;
placeholder?: string;
/** Shown in place of the status line while the field is empty. */
emptyHint?: string;
/** Tailwind classes for the text input, so each caller keeps its local
* convention (the host settings panel and the project Config tab do not
* style their inputs the same way). */
inputClassName: string;
}
/**
* Path field for a corporate CA certificate — a single file *or* a directory
* of them — shared by the global setting and the per-project override.
*
* Two Browse buttons rather than one: the platform file dialog cannot offer
* "a file or a folder" in a single call, and which one the user wants is not
* guessable (a lone `corp-root.pem` is as common as a folder of chained certs).
*
* The status line is what makes the feature debuggable. It reports the
* certificate count and, crucially, the `.crt` names each file is installed
* as: `update-ca-certificates` matches `*.crt` case-sensitively and ignores a
* `.pem` in complete silence, so seeing `corp-root.pem → corp-root.crt` is the
* difference between trusting the setting and guessing at it.
*/
export default function CaCertPathInput({
id,
value,
onChange,
onCommit,
disabled = false,
placeholder,
emptyHint,
inputClassName,
}: Props) {
const [info, setInfo] = useState<CaCertInfo | null>(null);
// Guards against a slow inspect for an earlier value landing after a newer
// one and describing the wrong path.
const requestId = useRef(0);
useEffect(() => {
const trimmed = value.trim();
if (!trimmed) {
setInfo(null);
return;
}
const id = ++requestId.current;
const timer = setTimeout(() => {
inspectCaCertPath(trimmed)
.then((result) => {
if (requestId.current === id) setInfo(result);
})
.catch(() => {
if (requestId.current === id) setInfo(null);
});
}, 250);
return () => clearTimeout(timer);
}, [value]);
const browse = async (directory: boolean) => {
const selected = await open({ directory, multiple: false });
if (typeof selected === "string") {
onChange(selected);
onCommit(selected);
}
};
return (
<div className="space-y-1.5">
<div className="flex gap-1.5">
<input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => onCommit(value)}
placeholder={placeholder}
disabled={disabled}
className={inputClassName}
/>
<Button size="md" disabled={disabled} onClick={() => browse(false)}>
File
</Button>
<Button size="md" disabled={disabled} onClick={() => browse(true)}>
Folder
</Button>
</div>
<CaCertStatus value={value} info={info} emptyHint={emptyHint} />
</div>
);
}
function CaCertStatus({
value,
info,
emptyHint,
}: {
value: string;
info: CaCertInfo | null;
emptyHint?: string;
}) {
if (!value.trim()) {
return emptyHint ? (
<p className="text-xs text-[var(--text-secondary)]">{emptyHint}</p>
) : null;
}
if (!info) return null;
if (info.error) {
// Glyph + word, never colour alone.
return (
<p className="text-xs text-[var(--error)]" role="status">
<span aria-hidden="true"> </span>
Problem: {info.error}
</p>
);
}
if (info.cert_count === 0) return null;
return (
<p className="text-xs text-[var(--success)]" role="status">
<span aria-hidden="true"> </span>
Found {info.cert_count} certificate{info.cert_count === 1 ? "" : "s"}
{info.installed_names.length > 0 && (
<span className="text-[var(--text-secondary)]">
{" "}
installed as {info.installed_names.slice(0, 4).join(", ")}
{info.installed_names.length > 4
? ` and ${info.installed_names.length - 4} more`
: ""}
</span>
)}
</p>
);
}
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { useSettings } from "../../hooks/useSettings";
import CaCertPathInput from "./CaCertPathInput";
const INPUT_CLASS =
"flex-1 min-w-0 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]";
/**
* Global corporate CA certificate setting.
*
* Applies to every project unless one overrides it in Project Home → Config →
* Access. Changing it recreates each container on its next start — the
* certificate is copied into the container's trust store once, at start, so
* there is nowhere else for a change to land.
*/
export default function CertificateSettings() {
const { appSettings, saveSettings } = useSettings();
const [path, setPath] = useState(appSettings?.ca_cert_path ?? "");
useEffect(() => {
setPath(appSettings?.ca_cert_path ?? "");
}, [appSettings?.ca_cert_path]);
const commit = async (value: string) => {
if (!appSettings) return;
const next = value.trim() || null;
if (next === appSettings.ca_cert_path) return;
await saveSettings({ ...appSettings, ca_cert_path: next });
};
return (
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="global-ca-cert-path"
>
Corporate CA Certificate
</label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
A certificate file, or a folder of them, for organisations whose network
inspects TLS. Mounted read-only into every container and trusted by
curl, git, npm, pip, Chromium and Claude Code itself. Per-project
settings override this; changing it recreates containers on next start.
</p>
<CaCertPathInput
id="global-ca-cert-path"
value={path}
onChange={setPath}
onCommit={commit}
placeholder="/etc/ssl/certs/corp-root.pem"
emptyHint="Not set — containers trust only the public CAs shipped with the image."
inputClassName={INPUT_CLASS}
/>
</div>
);
}
@@ -18,6 +18,7 @@ import Toggle from "../ui/Toggle";
import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
import CertificateSettings from "./CertificateSettings";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
@@ -172,6 +173,10 @@ export default function SettingsPanel() {
<DockerSettings />
</AccordionSection>
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
<CertificateSettings />
</AccordionSection>
<AccordionSection id="git-ssh" title="Git / SSH" defaultOpen={false}>
{/* Default SSH Key Directory */}
<div>
+5 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -37,6 +37,10 @@ export const detectAwsConfig = () =>
invoke<string | null>("detect_aws_config");
export const listAwsProfiles = () =>
invoke<string[]>("list_aws_profiles");
/** Check a corporate CA path and report what would be installed. Never
* rejects for a bad path — the reason comes back in `error`. */
export const inspectCaCertPath = (path: string) =>
invoke<CaCertInfo>("inspect_ca_cert_path", { path });
export const detectHostTimezone = () =>
invoke<string>("detect_host_timezone");
+24
View File
@@ -44,6 +44,10 @@ export interface Project {
/** null = not set → falls back to `full_permissions` (true → "bypass"). */
permission_mode: PermissionMode | null;
ssh_key_path: string | null;
/** Per-project override for the corporate CA certificate path (a single
* certificate file or a directory of them). null falls back to
* `AppSettings.ca_cert_path`. Changing it recreates the container. */
ca_cert_path: string | null;
git_token: string | null;
git_user_name: string | null;
git_user_email: string | null;
@@ -190,6 +194,12 @@ export interface GlobalOpenAiCompatibleSettings {
export interface AppSettings {
default_ssh_key_path: string | null;
/** Corporate root CA — a single certificate file or a directory of them —
* mounted read-only into every container and installed into the system
* trust store, Node's `NODE_EXTRA_CA_CERTS`, Python's
* `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE` and Chrome's NSS database.
* Needed when the host is behind a TLS-terminating corporate proxy. */
ca_cert_path: string | null;
default_git_user_name: string | null;
default_git_user_email: string | null;
docker_socket_path: string | null;
@@ -212,6 +222,20 @@ export interface AppSettings {
global_claude_code_settings: ClaudeCodeSettings | null;
}
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride
* in the payload rather than rejecting, so the field can render them inline
* while the user is still typing. */
export interface CaCertInfo {
exists: boolean;
is_directory: boolean;
cert_count: number;
/** The `.crt` names the certificates are installed as inside the container —
* surfacing the silent `.pem` → `.crt` rename that
* `update-ca-certificates` requires. */
installed_names: string[];
error: string | null;
}
export interface SttSettings {
enabled: boolean;
model: string;