Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57b6b71772 | ||
|
|
77567ac2ae | ||
|
|
247f03b48c | ||
|
|
31c73adb13 | ||
|
|
7a5823cb2b | ||
|
|
c3f92674b1 |
@@ -201,6 +201,36 @@ ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler tasks an
|
|||||||
re-attach for free when a container is recreated from a *different* image — which is what makes
|
re-attach for free when a container is recreated from a *different* image — which is what makes
|
||||||
base-image migration cheap.
|
base-image migration cheap.
|
||||||
|
|
||||||
|
### Corporate CA certificates (`docker/ca_certs.rs`, `entrypoint.sh`)
|
||||||
|
|
||||||
|
A global `AppSettings::ca_cert_path` with a per-project `Project::ca_cert_path` override, accepting
|
||||||
|
a single certificate file **or** a directory. Follows the SSH/AWS host-mount pattern: read-only
|
||||||
|
bind mount at `/tmp/.host-ca`, applied by the entrypoint on every start, so it survives recreation,
|
||||||
|
migration and Reset. Four things here are not obvious:
|
||||||
|
|
||||||
|
- **`update-ca-certificates` globs `*.crt`, case-sensitively.** A `.pem` that is merely copied into
|
||||||
|
`/usr/local/share/ca-certificates/` is ignored in total silence. Certificates are *renamed* —
|
||||||
|
`container_cert_name()` in Rust, mirrored in a few lines of shell in `entrypoint.sh` (the Rust
|
||||||
|
side carries the unit tests). A single-file mount lands at `/tmp/.host-ca/<name>.crt` so the
|
||||||
|
entrypoint only ever sees a directory and the file keeps a recognisable name.
|
||||||
|
- **The system 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`;
|
||||||
|
Chrome/Chromium read neither and want their own NSS database at `~/.pki/nssdb`, seeded with
|
||||||
|
`certutil` (`libnss3-tools`, added to the image for this). The NSS step warns and continues if
|
||||||
|
`certutil` is missing rather than failing the start.
|
||||||
|
- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal
|
||||||
|
session is a `docker exec`, which inherits the container's configured env and sees nothing the
|
||||||
|
entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. The bundle path
|
||||||
|
is deterministic (`/etc/ssl/certs/ca-certificates.crt`), so Rust can set them up front. They are
|
||||||
|
emitted **empty** when no CA is configured, for the `MANAGED_AUTH_KEYS` reason: `docker commit`
|
||||||
|
bakes env into the snapshot image. Empty is safe — verified on Ubuntu 24.04 that curl, `openssl
|
||||||
|
s_client` and Python's `ssl` behave exactly as with the vars unset.
|
||||||
|
- **`triple-c.ca-fingerprint` covers the certificate *bytes*, not just the path.** Replacing a
|
||||||
|
rotated CA at the same location must recreate the container; the copy inside is made once, at
|
||||||
|
start, so nothing else would notice. The entrypoint is stamped/idempotent on restart, and
|
||||||
|
actively **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides the
|
||||||
|
project's snapshot image, so turning the feature off has to undo, not merely stop.
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||||
|
|||||||
+36
-2
@@ -509,6 +509,10 @@ This lives in the sidebar under **Settings → Claude Authentication**.
|
|||||||
code to copy — this flow finishes on an Anthropic-hosted page, not a local callback.
|
code to copy — this flow finishes on an Anthropic-hosted page, not a local callback.
|
||||||
4. Paste the code back into Triple-C. The token is captured and written straight to the keychain.
|
4. Paste the code back into Triple-C. The token is captured and written straight to the keychain.
|
||||||
|
|
||||||
|
The code is long and easy to truncate. If Anthropic refuses it, the dialog says so and lets you
|
||||||
|
paste another one without restarting the sign-in — the CLI is still waiting. After a few refusals
|
||||||
|
the flow gives up and reports it rather than sitting there.
|
||||||
|
|
||||||
Only one sign-in can run at a time, and the whole flow times out after 15 minutes. A long-lived
|
Only one sign-in can run at a time, and the whole flow times out after 15 minutes. A long-lived
|
||||||
token requires a Claude subscription; without one, `setup-token` finishes without printing a token
|
token requires a Claude subscription; without one, `setup-token` finishes without printing a token
|
||||||
and nothing is stored.
|
and nothing is stored.
|
||||||
@@ -824,8 +828,8 @@ Notes:
|
|||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
Access global settings via the **Settings** tab in the sidebar. The panel is a set of collapsible
|
Access global settings via the **Settings** tab in the sidebar. The panel is a set of collapsible
|
||||||
sections: **General**, **Claude Authentication**, **Backends**, **Container**, **Git / SSH**,
|
sections: **General**, **Claude Authentication**, **Backends**, **Container**, **Certificates**,
|
||||||
**Tools** and **Updates**.
|
**Git / SSH**, **Tools** and **Updates**.
|
||||||
|
|
||||||
### Claude Authentication
|
### Claude Authentication
|
||||||
|
|
||||||
@@ -855,6 +859,36 @@ Environment variables applied to **all** project containers. Per-project variabl
|
|||||||
|
|
||||||
Path to your SSH key directory (typically `~/.ssh`). This is mounted into **all** containers that don't have a per-project SSH path set. Per-project SSH paths take precedence.
|
Path to your SSH key directory (typically `~/.ssh`). This is mounted into **all** containers that don't have a per-project SSH path set. Per-project SSH paths take precedence.
|
||||||
|
|
||||||
|
### Corporate CA Certificate
|
||||||
|
|
||||||
|
If your organisation's network inspects TLS (a corporate proxy, a VPN that terminates HTTPS at the
|
||||||
|
edge), containers need your organisation's root certificate or **every** HTTPS call inside them
|
||||||
|
fails — `npm install`, `pip`, `git clone` over HTTPS, `curl`, the browser-view pane, and Claude
|
||||||
|
Code's own calls to the API.
|
||||||
|
|
||||||
|
Point this at either a **single certificate file** or a **folder** of them. It is mounted read-only
|
||||||
|
into every container and applied on every start, so it survives container recreation, base-image
|
||||||
|
migration and Reset — unlike a certificate you install by hand inside a running container, which is
|
||||||
|
lost the first time any of those happens.
|
||||||
|
|
||||||
|
The status line under the field tells you how many certificates were found and the names they will
|
||||||
|
be installed as inside the container. That rename matters: the container's trust store only reads
|
||||||
|
files ending in `.crt`, so a `.pem` is renamed rather than merely copied, which is the step that is
|
||||||
|
easiest to get wrong by hand.
|
||||||
|
|
||||||
|
Inside the container the certificate is trusted by:
|
||||||
|
|
||||||
|
| Consumer | How |
|
||||||
|
|---|---|
|
||||||
|
| curl, git, apt, wget | the system trust store (`update-ca-certificates`) |
|
||||||
|
| Node, npm, **Claude Code itself** | `NODE_EXTRA_CA_CERTS` |
|
||||||
|
| Python, pip, requests | `REQUESTS_CA_BUNDLE` and `SSL_CERT_FILE` |
|
||||||
|
| Chrome / Chromium (browser view) | its own NSS database at `~/.pki/nssdb` |
|
||||||
|
|
||||||
|
A per-project override lives in **Project Home → Config → Access**; leave it blank to use this
|
||||||
|
global setting. Changing either recreates the project's container on its next start — replacing the
|
||||||
|
certificate file in place counts as a change, so a rotated CA is picked up too.
|
||||||
|
|
||||||
### Default Git Name / Email
|
### Default Git Name / Email
|
||||||
|
|
||||||
Sets `git user.name` and `git user.email` inside all containers. Per-project Git Name / Email settings take precedence. This is useful so you don't have to set the same name and email on every project.
|
Sets `git user.name` and `git user.email` inside all containers. Per-project Git Name / Email settings take precedence. This is useful so you don't have to set the same name and email on every project.
|
||||||
|
|||||||
+23
-1
@@ -245,9 +245,31 @@ minutes.
|
|||||||
|
|
||||||
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
|
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
|
||||||
frontend, never written to a log, and no command accepts or returns it.
|
frontend, never written to a log, and no command accepts or returns it.
|
||||||
|
- **The sign-in URL comes from the OSC 8 parameter, not the screen.** The CLI emits the URL as a
|
||||||
|
hyperlink and slices the *visible* text of it to the terminal width — measured against 2.1.226, a
|
||||||
|
346-character URL arrives at 80 columns as five separate hyperlink emissions, each carrying the
|
||||||
|
whole URL in its parameter and 80 characters of it on screen. Scraping the visible text yields a
|
||||||
|
URL that parses, points at `claude.com`, and cannot authorise anything, so the ANSI stripper
|
||||||
|
surfaces the hyperlink target and `claude-token-link` carries it to the UI. The frontend applies
|
||||||
|
the `ANTHROPIC_SIGN_IN_HOSTS` allowlist to it before display and again before `openUrl` — an OSC 8
|
||||||
|
parameter is container output that is never rendered, which makes it the *easier* place to hide a
|
||||||
|
hostile host, not a trusted one. `stty cols 400` (up from 200, which the URL still overflowed)
|
||||||
|
removes wrapping as a variable elsewhere, but it is not the fix: that line fails silently.
|
||||||
|
- **A rejected code is recoverable, not a hang.** On a bad paste the CLI prints
|
||||||
|
`OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin rather than exiting.
|
||||||
|
The streamed output is scanned for that, `claude-token-code-rejected` reopens the input with an
|
||||||
|
explanation, and the Enter is sent so the next code has a prompt to land in — bounded by
|
||||||
|
`MAX_CODE_ATTEMPTS`, after which the flow reports a failure. Without this the exec sat until the
|
||||||
|
15-minute timeout with the UI still saying "Finishing sign-in".
|
||||||
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
|
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
|
||||||
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
|
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
|
||||||
could still grow into a secret across a chunk boundary.
|
could still grow into a secret across a chunk boundary. A credential split across a hard line
|
||||||
|
wrap is reassembled by both the parser and the redactor from the same `scan_credential_body`, so
|
||||||
|
the two cannot disagree about where a credential ends — previously a wrapped token was rejected
|
||||||
|
as too short *and* its second line, which carries no `sk-ant-` marker, was printed to the UI in
|
||||||
|
clear. A run is only joined across a break that sits at a plausible terminal margin and is not
|
||||||
|
already long enough to be a whole credential; otherwise a repainting TUI would weld one frame's
|
||||||
|
token onto the next frame's first word.
|
||||||
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
|
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
|
||||||
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
|
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
|
||||||
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
|
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,7 @@ pub(crate) async fn create_container_for_project(
|
|||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
settings.global_claude_code_settings.as_ref(),
|
settings.global_claude_code_settings.as_ref(),
|
||||||
settings.default_ssh_key_path.as_deref(),
|
settings.default_ssh_key_path.as_deref(),
|
||||||
|
settings.ca_cert_path.as_deref(),
|
||||||
settings.default_git_user_name.as_deref(),
|
settings.default_git_user_name.as_deref(),
|
||||||
settings.default_git_user_email.as_deref(),
|
settings.default_git_user_email.as_deref(),
|
||||||
)
|
)
|
||||||
@@ -406,6 +407,7 @@ pub async fn start_project_container(
|
|||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
settings.global_claude_code_settings.as_ref(),
|
settings.global_claude_code_settings.as_ref(),
|
||||||
settings.default_ssh_key_path.as_deref(),
|
settings.default_ssh_key_path.as_deref(),
|
||||||
|
settings.ca_cert_path.as_deref(),
|
||||||
settings.default_git_user_name.as_deref(),
|
settings.default_git_user_name.as_deref(),
|
||||||
settings.default_git_user_email.as_deref(),
|
settings.default_git_user_email.as_deref(),
|
||||||
).await.unwrap_or(false);
|
).await.unwrap_or(false);
|
||||||
|
|||||||
@@ -155,6 +155,78 @@ pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
|||||||
Ok(None)
|
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]
|
#[tauri::command]
|
||||||
pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
|
pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
|
||||||
let mut profiles = Vec::new();
|
let mut profiles = Vec::new();
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBi
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use sha2::{Sha256, Digest};
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
|
use super::ca_certs;
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
|
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>,
|
timezone: Option<&str>,
|
||||||
global_claude_code_settings: Option<&ClaudeCodeSettings>,
|
global_claude_code_settings: Option<&ClaudeCodeSettings>,
|
||||||
default_ssh_key_path: Option<&str>,
|
default_ssh_key_path: Option<&str>,
|
||||||
|
default_ca_cert_path: Option<&str>,
|
||||||
default_git_user_name: Option<&str>,
|
default_git_user_name: Option<&str>,
|
||||||
default_git_user_email: Option<&str>,
|
default_git_user_email: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> 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 ──────────────────────────────
|
// ── Neutralize stale backend auth env vars ──────────────────────────────
|
||||||
// When a project switches backends (e.g. Bedrock → Anthropic) the container
|
// When a project switches backends (e.g. Bedrock → Anthropic) the container
|
||||||
// is recreated *from a snapshot image* committed off the previous 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.
|
// authenticating the container with a credential the user removed.
|
||||||
CLAUDE_OAUTH_TOKEN_ENV,
|
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
|
let already_set: std::collections::HashSet<String> = env_vars
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| e.split('=').next().map(|k| k.to_string()))
|
.filter_map(|e| e.split('=').next().map(|k| k.to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
for key in MANAGED_AUTH_KEYS {
|
for key in &managed_keys {
|
||||||
if !already_set.contains(*key) {
|
if !already_set.contains(*key) {
|
||||||
env_vars.push(format!("{}=", 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)
|
// AWS config mount (read-only)
|
||||||
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
|
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
|
||||||
let should_mount_aws = if project.backend == Backend::Bedrock {
|
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));
|
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled));
|
||||||
labels.insert("triple-c.instructions-fingerprint".to_string(),
|
labels.insert("triple-c.instructions-fingerprint".to_string(),
|
||||||
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
|
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-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-user-email".to_string(), effective_git_email.unwrap_or_default().to_string());
|
||||||
labels.insert("triple-c.git-token-hash".to_string(),
|
labels.insert("triple-c.git-token-hash".to_string(),
|
||||||
@@ -1911,6 +1973,7 @@ pub async fn container_needs_recreation(
|
|||||||
timezone: Option<&str>,
|
timezone: Option<&str>,
|
||||||
global_claude_code_settings: Option<&ClaudeCodeSettings>,
|
global_claude_code_settings: Option<&ClaudeCodeSettings>,
|
||||||
default_ssh_key_path: Option<&str>,
|
default_ssh_key_path: Option<&str>,
|
||||||
|
default_ca_cert_path: Option<&str>,
|
||||||
default_git_user_name: Option<&str>,
|
default_git_user_name: Option<&str>,
|
||||||
default_git_user_email: Option<&str>,
|
default_git_user_email: Option<&str>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
@@ -2078,6 +2141,28 @@ pub async fn container_needs_recreation(
|
|||||||
return Ok(true);
|
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) ─────
|
// ── Git settings (label-based to avoid stale snapshot env vars) ─────
|
||||||
let expected_git_name = project.git_user_name.as_deref()
|
let expected_git_name = project.git_user_name.as_deref()
|
||||||
.or(default_git_user_name)
|
.or(default_git_user_name)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod ca_certs;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod container;
|
pub mod container;
|
||||||
pub mod image;
|
pub mod image;
|
||||||
@@ -23,3 +24,6 @@ pub use exec::*;
|
|||||||
pub use legacy_cleanup::*;
|
pub use legacy_cleanup::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use migration::*;
|
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.
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ pub fn run() {
|
|||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
commands::settings_commands::pull_image,
|
commands::settings_commands::pull_image,
|
||||||
commands::settings_commands::detect_aws_config,
|
commands::settings_commands::detect_aws_config,
|
||||||
|
commands::settings_commands::inspect_ca_cert_path,
|
||||||
commands::settings_commands::list_aws_profiles,
|
commands::settings_commands::list_aws_profiles,
|
||||||
commands::settings_commands::detect_host_timezone,
|
commands::settings_commands::detect_host_timezone,
|
||||||
// Terminal
|
// Terminal
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ pub struct GlobalOpenAiCompatibleSettings {
|
|||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_ssh_key_path: Option<String>,
|
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)]
|
#[serde(default)]
|
||||||
pub default_git_user_name: Option<String>,
|
pub default_git_user_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -197,6 +205,7 @@ impl Default for AppSettings {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
default_ssh_key_path: None,
|
default_ssh_key_path: None,
|
||||||
|
ca_cert_path: None,
|
||||||
default_git_user_name: None,
|
default_git_user_name: None,
|
||||||
default_git_user_email: None,
|
default_git_user_email: None,
|
||||||
docker_socket_path: None,
|
docker_socket_path: None,
|
||||||
|
|||||||
@@ -166,6 +166,13 @@ pub struct Project {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub permission_mode: Option<PermissionMode>,
|
pub permission_mode: Option<PermissionMode>,
|
||||||
pub ssh_key_path: Option<String>,
|
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)]
|
#[serde(skip_serializing, default)]
|
||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
pub git_user_name: Option<String>,
|
pub git_user_name: Option<String>,
|
||||||
@@ -363,6 +370,7 @@ impl Project {
|
|||||||
full_permissions: false,
|
full_permissions: false,
|
||||||
permission_mode: None,
|
permission_mode: None,
|
||||||
ssh_key_path: None,
|
ssh_key_path: None,
|
||||||
|
ca_cert_path: None,
|
||||||
git_token: None,
|
git_token: None,
|
||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
git_user_email: None,
|
git_user_email: None,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog";
|
|||||||
import type { Project } from "../../../../lib/types";
|
import type { Project } from "../../../../lib/types";
|
||||||
import Button from "../../../ui/Button";
|
import Button from "../../../ui/Button";
|
||||||
import Field, { ConfigGroup, inputClass } from "../../../ui/Field";
|
import Field, { ConfigGroup, inputClass } from "../../../ui/Field";
|
||||||
|
import CaCertPathInput from "../../../settings/CaCertPathInput";
|
||||||
import EnvVarsEditor from "../../EnvVarsEditor";
|
import EnvVarsEditor from "../../EnvVarsEditor";
|
||||||
import PortMappingsEditor from "../../PortMappingsEditor";
|
import PortMappingsEditor from "../../PortMappingsEditor";
|
||||||
|
|
||||||
@@ -20,12 +21,14 @@ export default function AccessSection({
|
|||||||
disabledReason,
|
disabledReason,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
|
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 [gitName, setGitName] = useState(project.git_user_name ?? "");
|
||||||
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
||||||
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSshKeyPath(project.ssh_key_path ?? "");
|
setSshKeyPath(project.ssh_key_path ?? "");
|
||||||
|
setCaCertPath(project.ca_cert_path ?? "");
|
||||||
setGitName(project.git_user_name ?? "");
|
setGitName(project.git_user_name ?? "");
|
||||||
setGitEmail(project.git_user_email ?? "");
|
setGitEmail(project.git_user_email ?? "");
|
||||||
setGitToken(project.git_token ?? "");
|
setGitToken(project.git_token ?? "");
|
||||||
@@ -114,6 +117,24 @@ export default function AccessSection({
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</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)]">
|
<div className="pt-2 border-t border-[var(--border-color)]">
|
||||||
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||||
Environment variables
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,22 @@ vi.mock("@tauri-apps/api/event", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** Every event the hook subscribes to, so the unmount test counts the right
|
||||||
|
* number of teardowns instead of a magic number that drifts. */
|
||||||
|
const EVENT_NAMES = [
|
||||||
|
"claude-token-progress",
|
||||||
|
"claude-token-output",
|
||||||
|
"claude-token-link",
|
||||||
|
"claude-token-code-rejected",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The sign-in URL at its real length (346 characters, measured against
|
||||||
|
* Claude Code 2.1.226) and the 80-column slice of it that is all the visible
|
||||||
|
* transcript ever contains. */
|
||||||
|
const FULL_URL =
|
||||||
|
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
||||||
|
const TRUNCATED_URL = FULL_URL.slice(0, 80);
|
||||||
|
|
||||||
function emitOutput(chunk: string, projectId = "p1") {
|
function emitOutput(chunk: string, projectId = "p1") {
|
||||||
act(() => {
|
act(() => {
|
||||||
handlers.get("claude-token-output")?.({
|
handlers.get("claude-token-output")?.({
|
||||||
@@ -39,6 +55,26 @@ function emitOutput(chunk: string, projectId = "p1") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitLink(url: string, projectId = "p1") {
|
||||||
|
act(() => {
|
||||||
|
handlers.get("claude-token-link")?.({
|
||||||
|
payload: { project_id: projectId, url },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitCodeRejected(message: string, attemptsRemaining: number) {
|
||||||
|
act(() => {
|
||||||
|
handlers.get("claude-token-code-rejected")?.({
|
||||||
|
payload: {
|
||||||
|
project_id: "p1",
|
||||||
|
message,
|
||||||
|
attempts_remaining: attemptsRemaining,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderModal(
|
function renderModal(
|
||||||
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
|
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
|
||||||
) {
|
) {
|
||||||
@@ -200,6 +236,93 @@ describe("ClaudeAuthModal", () => {
|
|||||||
const { unmount } = renderModal();
|
const { unmount } = renderModal();
|
||||||
await flowStarted();
|
await flowStarted();
|
||||||
unmount();
|
unmount();
|
||||||
await waitFor(() => expect(unlisten).toHaveBeenCalledTimes(2));
|
await waitFor(() =>
|
||||||
|
expect(unlisten).toHaveBeenCalledTimes(EVENT_NAMES.length),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── The hyperlink target, not the wrapped display text ────────────────
|
||||||
|
//
|
||||||
|
// `claude setup-token` slices the *visible* text of its OSC 8 hyperlink to
|
||||||
|
// the terminal width, so the transcript holds five 80-character pieces of a
|
||||||
|
// 346-character URL. The backend lifts the whole thing out of the hyperlink
|
||||||
|
// parameter and sends it on `claude-token-link`.
|
||||||
|
|
||||||
|
it("prefers the hyperlink target over the wrapped copy in the transcript", async () => {
|
||||||
|
renderModal();
|
||||||
|
await flowStarted();
|
||||||
|
|
||||||
|
// What the transcript holds: the first slice only.
|
||||||
|
emitOutput(`Browser didn't open? Use the url below to sign in\n${TRUNCATED_URL}\n`);
|
||||||
|
// What the hyperlink parameter holds: all of it.
|
||||||
|
emitLink(FULL_URL);
|
||||||
|
|
||||||
|
const link = await screen.findByRole("link", { name: FULL_URL });
|
||||||
|
fireEvent.click(link);
|
||||||
|
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
|
||||||
|
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
|
||||||
|
renderModal();
|
||||||
|
await flowStarted();
|
||||||
|
|
||||||
|
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
|
expect(openUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a hyperlink belonging to a different project", async () => {
|
||||||
|
renderModal();
|
||||||
|
await flowStarted();
|
||||||
|
|
||||||
|
emitLink(FULL_URL, "p2");
|
||||||
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A refused code is recoverable, not a hang ─────────────────────────
|
||||||
|
|
||||||
|
it("reports a rejected code and lets another one be submitted", async () => {
|
||||||
|
renderModal();
|
||||||
|
await flowStarted();
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Authentication code");
|
||||||
|
fireEvent.change(input, { target: { value: "truncated" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitClaudeTokenCode).toHaveBeenCalledWith("truncated"),
|
||||||
|
);
|
||||||
|
// Before the rejection arrives the UI claims the sign-in is completing.
|
||||||
|
expect(screen.getByText("Finishing sign-in")).toBeInTheDocument();
|
||||||
|
|
||||||
|
emitCodeRejected(
|
||||||
|
"That code was rejected — `claude setup-token` reports the full code was not copied. Copy it again from the Anthropic page and submit it; 2 attempts left.",
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reported, not waited out — and the flow is still live.
|
||||||
|
await screen.findByText(/That code was rejected/);
|
||||||
|
expect(screen.getByText("Code rejected — try again")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Finishing sign-in")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("claude-auth-error")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// A second code goes through without restarting the whole flow.
|
||||||
|
fireEvent.change(input, { target: { value: "the-whole-code" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitClaudeTokenCode).toHaveBeenLastCalledWith("the-whole-code"),
|
||||||
|
);
|
||||||
|
expect(acquireClaudeToken).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends with a reported failure when the retries run out", async () => {
|
||||||
|
acquireClaudeToken.mockRejectedValue(
|
||||||
|
"`claude setup-token` rejected the code 3 times, so the sign-in was abandoned. No token was stored.",
|
||||||
|
);
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
const banner = await screen.findByTestId("claude-auth-error");
|
||||||
|
expect(banner).toHaveTextContent(/rejected the code 3 times/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ interface Props {
|
|||||||
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
|
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
|
||||||
waiting: { tone: "busy", label: "Waiting for sign-in" },
|
waiting: { tone: "busy", label: "Waiting for sign-in" },
|
||||||
finishing: { tone: "busy", label: "Finishing sign-in" },
|
finishing: { tone: "busy", label: "Finishing sign-in" },
|
||||||
|
// The CLI refused a code and is back at its prompt. Distinct from "failed":
|
||||||
|
// the flow is still live and another code will be accepted.
|
||||||
|
rejected: { tone: "error", label: "Code rejected — try again" },
|
||||||
succeeded: { tone: "ok", label: "Token stored" },
|
succeeded: { tone: "ok", label: "Token stored" },
|
||||||
failed: { tone: "error", label: "Authentication failed" },
|
failed: { tone: "error", label: "Authentication failed" },
|
||||||
};
|
};
|
||||||
@@ -88,7 +91,9 @@ export default function ClaudeAuthModal({
|
|||||||
? PHASE_STATUS.failed
|
? PHASE_STATUS.failed
|
||||||
: flow.codeSubmitted
|
: flow.codeSubmitted
|
||||||
? PHASE_STATUS.finishing
|
? PHASE_STATUS.finishing
|
||||||
: PHASE_STATUS.waiting;
|
: flow.codeRejections > 0
|
||||||
|
? PHASE_STATUS.rejected
|
||||||
|
: PHASE_STATUS.waiting;
|
||||||
|
|
||||||
// Split for display only. `flow.signInUrl` has already passed the host
|
// Split for display only. `flow.signInUrl` has already passed the host
|
||||||
// allowlist; this decides which half of it an ellipsis is allowed to eat.
|
// allowlist; this decides which half of it an ellipsis is allowed to eat.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import Toggle from "../ui/Toggle";
|
|||||||
import WebTerminalSettings from "./WebTerminalSettings";
|
import WebTerminalSettings from "./WebTerminalSettings";
|
||||||
import SttSettings from "./SttSettings";
|
import SttSettings from "./SttSettings";
|
||||||
import SharedAuthSettings from "./SharedAuthSettings";
|
import SharedAuthSettings from "./SharedAuthSettings";
|
||||||
|
import CertificateSettings from "./CertificateSettings";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
@@ -172,6 +173,10 @@ export default function SettingsPanel() {
|
|||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
|
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
|
||||||
|
<CertificateSettings />
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
<AccordionSection id="git-ssh" title="Git / SSH" defaultOpen={false}>
|
<AccordionSection id="git-ssh" title="Git / SSH" defaultOpen={false}>
|
||||||
{/* Default SSH Key Directory */}
|
{/* Default SSH Key Directory */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { authErrorMessage, extractSignInUrl } from "./useClaudeAuth";
|
import {
|
||||||
|
authErrorMessage,
|
||||||
|
extractSignInUrl,
|
||||||
|
pickSignInUrl,
|
||||||
|
} from "./useClaudeAuth";
|
||||||
|
|
||||||
describe("extractSignInUrl", () => {
|
describe("extractSignInUrl", () => {
|
||||||
it("finds the authorize URL in realistic setup-token output", () => {
|
it("finds the authorize URL in realistic setup-token output", () => {
|
||||||
@@ -79,6 +83,64 @@ describe("extractSignInUrl", () => {
|
|||||||
const second = "https://platform.claude.com/oauth/authorize?code=true&more=1";
|
const second = "https://platform.claude.com/oauth/authorize?code=true&more=1";
|
||||||
expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first);
|
expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Why the scraper is only the fallback ─────────────────────────────────
|
||||||
|
// `claude setup-token` emits the URL as an OSC 8 hyperlink and slices the
|
||||||
|
// *visible* text of it to the terminal width, so the transcript holds five
|
||||||
|
// 80-character pieces of a 346-character URL. Each piece is a valid,
|
||||||
|
// Anthropic-hosted, oauth-looking URL — and none of them authorises
|
||||||
|
// anything.
|
||||||
|
|
||||||
|
it("cannot recover a URL the CLI sliced across lines, which is why the hyperlink wins", () => {
|
||||||
|
const slices = [
|
||||||
|
FULL_URL.slice(0, 80),
|
||||||
|
FULL_URL.slice(80, 160),
|
||||||
|
FULL_URL.slice(160, 240),
|
||||||
|
FULL_URL.slice(240, 320),
|
||||||
|
FULL_URL.slice(320),
|
||||||
|
];
|
||||||
|
const scraped = extractSignInUrl(slices.join("\n"));
|
||||||
|
|
||||||
|
// Documenting the limit, not endorsing it: the pieces share no prefix, so
|
||||||
|
// the "extends the current pick" rule cannot join them, and guessing at
|
||||||
|
// line joins on an untrusted stream is not on the table.
|
||||||
|
expect(scraped).toBe(slices[0]);
|
||||||
|
expect(scraped).not.toBe(FULL_URL);
|
||||||
|
|
||||||
|
// The hyperlink parameter carries the whole thing, and that is what the
|
||||||
|
// hook prefers.
|
||||||
|
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The real sign-in URL, at its measured length (346 characters, Claude Code
|
||||||
|
* 2.1.226). */
|
||||||
|
const FULL_URL =
|
||||||
|
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
||||||
|
|
||||||
|
describe("pickSignInUrl", () => {
|
||||||
|
it("keeps a 346-character authorize URL intact", () => {
|
||||||
|
expect(FULL_URL).toHaveLength(346);
|
||||||
|
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the same host allowlist to a hyperlink target", () => {
|
||||||
|
// An OSC 8 parameter is container output like anything else, and it is
|
||||||
|
// never displayed — so it is the *easier* place to hide a hostile host.
|
||||||
|
expect(pickSignInUrl(["https://evil.tld/cai/oauth/authorize"])).toBeNull();
|
||||||
|
expect(
|
||||||
|
pickSignInUrl(["https://claude.ai@evil.tld/oauth/authorize"]),
|
||||||
|
).toBeNull();
|
||||||
|
expect(pickSignInUrl(["javascript:alert(1)"])).toBeNull();
|
||||||
|
expect(pickSignInUrl([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a later hyperlink displace the one already shown", () => {
|
||||||
|
const real = `${FULL_URL}`;
|
||||||
|
const spoof = "https://claude.com.evil.tld/cai/oauth/authorize?code=true";
|
||||||
|
expect(pickSignInUrl([real, spoof])).toBe(real);
|
||||||
|
expect(pickSignInUrl([spoof, real])).toBe(real);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("authErrorMessage", () => {
|
describe("authErrorMessage", () => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
|
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
|
||||||
import type {
|
import type {
|
||||||
|
ClaudeTokenCodeRejectedEvent,
|
||||||
|
ClaudeTokenLinkEvent,
|
||||||
ClaudeTokenOutputEvent,
|
ClaudeTokenOutputEvent,
|
||||||
ClaudeTokenProgressEvent,
|
ClaudeTokenProgressEvent,
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
@@ -19,10 +21,17 @@ import type {
|
|||||||
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
|
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
|
||||||
const PROGRESS_EVENT = "claude-token-progress";
|
const PROGRESS_EVENT = "claude-token-progress";
|
||||||
const OUTPUT_EVENT = "claude-token-output";
|
const OUTPUT_EVENT = "claude-token-output";
|
||||||
|
const LINK_EVENT = "claude-token-link";
|
||||||
|
const CODE_REJECTED_EVENT = "claude-token-code-rejected";
|
||||||
|
|
||||||
/** Bound on the retained transcript. The tail is the interesting part. */
|
/** Bound on the retained transcript. The tail is the interesting part. */
|
||||||
const MAX_OUTPUT = 64 * 1024;
|
const MAX_OUTPUT = 64 * 1024;
|
||||||
|
|
||||||
|
/** Bound on retained sign-in candidates. The backend already deduplicates
|
||||||
|
* consecutive repeats; this stops a container that prints a fresh hyperlink
|
||||||
|
* every frame from growing state without limit. */
|
||||||
|
const MAX_LINKS = 16;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
|
* Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
|
||||||
* backend writes its errors as complete, actionable sentences ("The container
|
* backend writes its errors as complete, actionable sentences ("The container
|
||||||
@@ -38,13 +47,13 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the sign-in URL out of `claude setup-token`'s transcript.
|
* Choose one sign-in URL from a list of candidates.
|
||||||
*
|
*
|
||||||
* **The transcript is container output, so every candidate here is
|
* **Every candidate is container output, so all of them are
|
||||||
* attacker-controlled if the sandboxed agent misbehaves.** It is then rendered
|
* attacker-controlled if the sandboxed agent misbehaves.** The winner is
|
||||||
* under a heading that says "Sign in with Anthropic" and handed to the host
|
* rendered under a heading that says "Sign in with Anthropic" and handed to the
|
||||||
* browser, which makes this the highest-value URL in the app to spoof: a user
|
* host browser, which makes this the highest-value URL in the app to spoof: a
|
||||||
* who follows it types their real Anthropic credentials into whatever it
|
* user who follows it types their real Anthropic credentials into whatever it
|
||||||
* resolves to. Three rules follow, and none of them are optional:
|
* resolves to. Three rules follow, and none of them are optional:
|
||||||
*
|
*
|
||||||
* - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a
|
* - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a
|
||||||
@@ -60,14 +69,8 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
|||||||
* the complete one — and it cannot swap the origin, because a longer string
|
* the complete one — and it cannot swap the origin, because a longer string
|
||||||
* with the same prefix has the same host.
|
* with the same prefix has the same host.
|
||||||
*/
|
*/
|
||||||
export function extractSignInUrl(text: string): string | null {
|
export function pickSignInUrl(candidates: readonly string[]): string | null {
|
||||||
// eslint-disable-next-line no-control-regex
|
const cleaned = candidates
|
||||||
const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
|
|
||||||
if (!matches) return null;
|
|
||||||
|
|
||||||
const cleaned = matches
|
|
||||||
// Trailing punctuation belongs to the prose, not the URL.
|
|
||||||
.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, ""))
|
|
||||||
.map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
|
.map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
|
||||||
.filter((url): url is string => url !== null);
|
.filter((url): url is string => url !== null);
|
||||||
|
|
||||||
@@ -81,6 +84,33 @@ export function extractSignInUrl(text: string): string | null {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrape a sign-in URL out of `claude setup-token`'s visible transcript.
|
||||||
|
*
|
||||||
|
* **This is the fallback, not the primary route.** The CLI emits the URL as an
|
||||||
|
* OSC 8 hyperlink and slices the *visible* text of that hyperlink to the
|
||||||
|
* terminal width — measured at 80 columns, a 346-character URL arrives as five
|
||||||
|
* 80-character pieces on five lines. Nothing scraping the visible text can put
|
||||||
|
* those back together: the pieces share no prefix, so the "extends the current
|
||||||
|
* pick" rule cannot join them, and joining adjacent lines by guesswork on an
|
||||||
|
* untrusted stream is exactly the sort of thing the rules above exist to
|
||||||
|
* forbid. What comes out is the first 80 characters — a URL that parses, that
|
||||||
|
* points at claude.com, and that cannot authorise anything.
|
||||||
|
*
|
||||||
|
* So the backend lifts the whole URL out of the hyperlink parameter and sends
|
||||||
|
* it on `claude-token-link`, and {@link useClaudeTokenAcquisition} prefers that.
|
||||||
|
* This remains for CLI versions that print a bare URL with no hyperlink at all,
|
||||||
|
* where a URL narrow enough not to wrap is recovered correctly.
|
||||||
|
*/
|
||||||
|
export function extractSignInUrl(text: string): string | null {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
// Trailing punctuation belongs to the prose, not the URL.
|
||||||
|
return pickSignInUrl(matches.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, "")));
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Token presence
|
// Token presence
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -133,6 +163,12 @@ export interface ClaudeTokenAcquisition {
|
|||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
codeSubmitted: boolean;
|
codeSubmitted: boolean;
|
||||||
submitError: string | null;
|
submitError: string | null;
|
||||||
|
/**
|
||||||
|
* How many codes `claude setup-token` has refused. Non-zero means the CLI is
|
||||||
|
* still alive and waiting for another one — a recoverable state, not the end
|
||||||
|
* of the flow.
|
||||||
|
*/
|
||||||
|
codeRejections: number;
|
||||||
submitCode: (code: string) => Promise<boolean>;
|
submitCode: (code: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +190,13 @@ export function useClaudeTokenAcquisition(
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [codeRejections, setCodeRejections] = useState(0);
|
||||||
|
// Candidates from `claude-token-link`, in arrival order. Kept as a list
|
||||||
|
// rather than a single value so `pickSignInUrl` applies the same first-wins
|
||||||
|
// rule here as it does to the scraped transcript — the CLI reprints the same
|
||||||
|
// hyperlink after every retry, and a *different* one arriving later must not
|
||||||
|
// be able to displace the one the user was already shown.
|
||||||
|
const [links, setLinks] = useState<string[]>([]);
|
||||||
|
|
||||||
// Held in a ref so a fresh callback identity cannot restart the flow.
|
// Held in a ref so a fresh callback identity cannot restart the flow.
|
||||||
const succeededRef = useRef(onSucceeded);
|
const succeededRef = useRef(onSucceeded);
|
||||||
@@ -193,6 +236,26 @@ export function useClaudeTokenAcquisition(
|
|||||||
: next;
|
: next;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await register<ClaudeTokenLinkEvent>(LINK_EVENT, (payload) => {
|
||||||
|
if (payload.project_id !== projectId) return;
|
||||||
|
setLinks((prev) =>
|
||||||
|
prev.includes(payload.url) || prev.length >= MAX_LINKS
|
||||||
|
? prev
|
||||||
|
: [...prev, payload.url],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await register<ClaudeTokenCodeRejectedEvent>(
|
||||||
|
CODE_REJECTED_EVENT,
|
||||||
|
(payload) => {
|
||||||
|
if (payload.project_id !== projectId) return;
|
||||||
|
// The CLI is alive and back at its prompt, so this is a correction
|
||||||
|
// the user can act on — not a failure. Re-open the input and say
|
||||||
|
// why, rather than leaving "Finishing sign-in" on screen forever.
|
||||||
|
setCodeRejections((n) => n + 1);
|
||||||
|
setCodeSubmitted(false);
|
||||||
|
setSubmitError(payload.message);
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPhase("failed");
|
setPhase("failed");
|
||||||
@@ -261,7 +324,13 @@ export function useClaudeTokenAcquisition(
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signInUrl = useMemo(() => extractSignInUrl(output), [output]);
|
// The hyperlink parameter wins whenever there is one: it is the only place
|
||||||
|
// the CLI emits the URL contiguously. Scraping the visible text is the
|
||||||
|
// fallback for versions that print a bare URL — see `extractSignInUrl`.
|
||||||
|
const signInUrl = useMemo(
|
||||||
|
() => pickSignInUrl(links) ?? extractSignInUrl(output),
|
||||||
|
[links, output],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phase,
|
phase,
|
||||||
@@ -272,6 +341,7 @@ export function useClaudeTokenAcquisition(
|
|||||||
submitting,
|
submitting,
|
||||||
codeSubmitted,
|
codeSubmitted,
|
||||||
submitError,
|
submitError,
|
||||||
|
codeRejections,
|
||||||
submitCode,
|
submitCode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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, BrowserSetupOutcome, BrowserInstallTarget, 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, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -37,6 +37,10 @@ export const detectAwsConfig = () =>
|
|||||||
invoke<string | null>("detect_aws_config");
|
invoke<string | null>("detect_aws_config");
|
||||||
export const listAwsProfiles = () =>
|
export const listAwsProfiles = () =>
|
||||||
invoke<string[]>("list_aws_profiles");
|
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 = () =>
|
export const detectHostTimezone = () =>
|
||||||
invoke<string>("detect_host_timezone");
|
invoke<string>("detect_host_timezone");
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export interface Project {
|
|||||||
/** null = not set → falls back to `full_permissions` (true → "bypass"). */
|
/** null = not set → falls back to `full_permissions` (true → "bypass"). */
|
||||||
permission_mode: PermissionMode | null;
|
permission_mode: PermissionMode | null;
|
||||||
ssh_key_path: string | 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_token: string | null;
|
||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
@@ -190,6 +194,12 @@ export interface GlobalOpenAiCompatibleSettings {
|
|||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
default_ssh_key_path: string | null;
|
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_name: string | null;
|
||||||
default_git_user_email: string | null;
|
default_git_user_email: string | null;
|
||||||
docker_socket_path: string | null;
|
docker_socket_path: string | null;
|
||||||
@@ -212,6 +222,20 @@ export interface AppSettings {
|
|||||||
global_claude_code_settings: ClaudeCodeSettings | null;
|
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 {
|
export interface SttSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -528,6 +552,26 @@ export interface ClaudeTokenOutputEvent {
|
|||||||
chunk: string;
|
chunk: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Payload of the `claude-token-link`: a sign-in URL taken from an OSC 8
|
||||||
|
* hyperlink parameter, which is the only place the CLI emits it whole — the
|
||||||
|
* visible text is sliced to the terminal width. **Untrusted**: it is container
|
||||||
|
* output, so it goes through `sanitizeRelayUrl` with the
|
||||||
|
* `ANTHROPIC_SIGN_IN_HOSTS` allowlist before it is shown or opened. */
|
||||||
|
export interface ClaudeTokenLinkEvent {
|
||||||
|
project_id: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload of `claude-token-code-rejected`: `claude setup-token` refused the
|
||||||
|
* submitted code and is parked waiting for another one. The flow is still
|
||||||
|
* alive, so this is recoverable — `attempts_remaining` is how many more codes
|
||||||
|
* the backend will pass on before giving up. */
|
||||||
|
export interface ClaudeTokenCodeRejectedEvent {
|
||||||
|
project_id: string;
|
||||||
|
message: string;
|
||||||
|
attempts_remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Container base-image migration ───────────────────────────────────────────
|
// ── Container base-image migration ───────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// A project's container is created from its own `triple-c-snapshot-<id>:latest`
|
// A project's container is created from its own `triple-c-snapshot-<id>:latest`
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ RUN for i in 1 2 3 4 5; do \
|
|||||||
jq \
|
jq \
|
||||||
sudo \
|
sudo \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
libnss3-tools \
|
||||||
gnupg \
|
gnupg \
|
||||||
locales \
|
locales \
|
||||||
unzip \
|
unzip \
|
||||||
@@ -35,6 +36,12 @@ RUN for i in 1 2 3 4 5; do \
|
|||||||
socat \
|
socat \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# `libnss3-tools` above provides `certutil`. Chrome/Chromium read neither
|
||||||
|
# /etc/ssl/certs nor $SSL_CERT_FILE — they have their own NSS database at
|
||||||
|
# ~/.pki/nssdb — so without it the browser-view pane cannot be made to trust a
|
||||||
|
# corporate CA, no matter what the system trust store says. entrypoint.sh
|
||||||
|
# degrades to a warning if it is ever missing.
|
||||||
|
|
||||||
# Remove default ubuntu user to free UID 1000 for host-user remapping
|
# Remove default ubuntu user to free UID 1000 for host-user remapping
|
||||||
RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \
|
RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \
|
||||||
&& if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi
|
&& if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi
|
||||||
|
|||||||
+162
-1
@@ -58,6 +58,167 @@ remap_uid_gid
|
|||||||
# Fix ownership of home directory after UID/GID change
|
# Fix ownership of home directory after UID/GID change
|
||||||
chown -R claude:claude /home/claude
|
chown -R claude:claude /home/claude
|
||||||
|
|
||||||
|
# ── Corporate CA certificates ───────────────────────────────────────────────
|
||||||
|
# The host's CA material is bind-mounted read-only at /tmp/.host-ca. Triple-C
|
||||||
|
# mounts a *directory* as-is and a *single file* as /tmp/.host-ca/<name>.crt,
|
||||||
|
# so this only ever has to deal with a directory (the file branch below is
|
||||||
|
# defensive).
|
||||||
|
#
|
||||||
|
# Runs before everything that touches the network — the git credential helper,
|
||||||
|
# ssh-keyscan, and especially the `claude update` at the bottom of this file,
|
||||||
|
# which is itself an HTTPS call that fails behind a TLS-terminating proxy
|
||||||
|
# without this.
|
||||||
|
#
|
||||||
|
# Two things are easy to get wrong here:
|
||||||
|
# 1. `update-ca-certificates` globs /usr/local/share/ca-certificates/*.crt
|
||||||
|
# case-sensitively. A `.pem` that is merely copied in is ignored in total
|
||||||
|
# silence, so certificates are *renamed*, not copied.
|
||||||
|
# 2. Chrome/Chromium read neither /etc/ssl nor $SSL_CERT_FILE; they have
|
||||||
|
# their own NSS database at ~/.pki/nssdb, seeded below with certutil.
|
||||||
|
#
|
||||||
|
# NODE_EXTRA_CA_CERTS / REQUESTS_CA_BUNDLE / SSL_CERT_FILE are deliberately NOT
|
||||||
|
# exported here. Every terminal is a separate `docker exec`, which inherits the
|
||||||
|
# container's configured env and sees nothing this script exported — the same
|
||||||
|
# reason $BROWSER had to become an image-level ENV. Triple-C sets them on the
|
||||||
|
# container at creation time instead. (They are forwarded into the cron
|
||||||
|
# environment file further down, because cron jobs start from a bare env.)
|
||||||
|
CA_SRC="/tmp/.host-ca"
|
||||||
|
CA_STORE="/usr/local/share/ca-certificates"
|
||||||
|
CA_PREFIX="triple-c-"
|
||||||
|
CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt"
|
||||||
|
CA_STAMP="/var/lib/triple-c/ca.stamp"
|
||||||
|
CA_NSSDB="/home/claude/.pki/nssdb"
|
||||||
|
|
||||||
|
# Mirror of `container_cert_name()` in app/src-tauri/src/docker/ca_certs.rs.
|
||||||
|
# The two must agree; the Rust side has the unit tests.
|
||||||
|
ca_normalise_name() {
|
||||||
|
local name stem
|
||||||
|
name=$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_')
|
||||||
|
while [ "${name#.}" != "$name" ]; do name="${name#.}"; done
|
||||||
|
stem="${name%.*}"
|
||||||
|
[ -z "$stem" ] && stem="corporate-ca"
|
||||||
|
printf '%s.crt' "$stem"
|
||||||
|
}
|
||||||
|
|
||||||
|
ca_source_files() {
|
||||||
|
if [ -d "$CA_SRC" ]; then
|
||||||
|
find "$CA_SRC" -maxdepth 1 -type f \
|
||||||
|
\( -iname '*.crt' -o -iname '*.pem' -o -iname '*.cer' \
|
||||||
|
-o -iname '*.cert' -o -iname '*.ca-bundle' \) 2>/dev/null | sort
|
||||||
|
elif [ -f "$CA_SRC" ]; then
|
||||||
|
printf '%s\n' "$CA_SRC"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Seed Chrome/Chromium's NSS database. Tolerant by design: a missing certutil
|
||||||
|
# or a broken profile must warn, never fail the container start.
|
||||||
|
# ~/.pki lives in the home volume, so this persists once done; the system store
|
||||||
|
# lives in the writable layer and is re-applied on every start.
|
||||||
|
ca_seed_nssdb() {
|
||||||
|
if ! command -v certutil >/dev/null 2>&1; then
|
||||||
|
echo "entrypoint: warning — certutil not found (install libnss3-tools); Chrome/Chromium in this container will not trust the corporate CA"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
su -s /bin/bash claude -c '
|
||||||
|
db="$HOME/.pki/nssdb"
|
||||||
|
mkdir -p "$db" || exit 1
|
||||||
|
if [ ! -f "$db/cert9.db" ]; then
|
||||||
|
certutil -d "sql:$db" -N --empty-password >/dev/null 2>&1 || exit 1
|
||||||
|
fi
|
||||||
|
for f in /usr/local/share/ca-certificates/triple-c-*.crt; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
nick="triple-c:$(basename "$f" .crt)"
|
||||||
|
# Delete first so re-running replaces rather than duplicates.
|
||||||
|
certutil -d "sql:$db" -D -n "$nick" >/dev/null 2>&1
|
||||||
|
certutil -d "sql:$db" -A -t "C,," -n "$nick" -i "$f" >/dev/null 2>&1 \
|
||||||
|
|| echo "entrypoint: warning — certutil could not add $nick"
|
||||||
|
done
|
||||||
|
' && echo "entrypoint: seeded Chrome/Chromium NSS database with the corporate CA" \
|
||||||
|
|| echo "entrypoint: warning — NSS database seeding failed (continuing)"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_corporate_ca() {
|
||||||
|
local files fp stamp f base name count installed
|
||||||
|
|
||||||
|
files=$(ca_source_files)
|
||||||
|
|
||||||
|
if [ -z "$files" ]; then
|
||||||
|
# Nothing configured — but /usr/local/share is in the writable layer and
|
||||||
|
# `docker commit` bakes it into the project's snapshot image, so a cert
|
||||||
|
# installed by a previous configuration would ride that snapshot into
|
||||||
|
# every future container. Turning the setting off has to actively undo.
|
||||||
|
if ls "$CA_STORE/$CA_PREFIX"*.crt >/dev/null 2>&1; then
|
||||||
|
echo "entrypoint: removing previously installed corporate CA certificates"
|
||||||
|
rm -f "$CA_STORE/$CA_PREFIX"*.crt
|
||||||
|
update-ca-certificates --fresh >/dev/null 2>&1 \
|
||||||
|
|| echo "entrypoint: warning — update-ca-certificates failed while removing certificates"
|
||||||
|
rm -f "$CA_STAMP"
|
||||||
|
fi
|
||||||
|
if [ -e "$CA_SRC" ]; then
|
||||||
|
echo "entrypoint: warning — $CA_SRC holds no certificate files"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Idempotent and cheap: the certs are already installed on a plain restart
|
||||||
|
# (the writable layer survives stop/start), so hash the sources and skip the
|
||||||
|
# work when nothing has moved. The NSS database is checked separately
|
||||||
|
# because it lives in the home volume and can be wiped independently.
|
||||||
|
fp=$(printf '%s\n' "$files" | xargs -d '\n' -r sha256sum 2>/dev/null | sha256sum | cut -d' ' -f1)
|
||||||
|
stamp=$(cat "$CA_STAMP" 2>/dev/null)
|
||||||
|
if [ -n "$fp" ] && [ "$fp" = "$stamp" ] && [ -s "$CA_BUNDLE" ]; then
|
||||||
|
if [ -f "$CA_NSSDB/cert9.db" ]; then
|
||||||
|
echo "entrypoint: corporate CA certificates already installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
ca_seed_nssdb
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$CA_STORE" "$(dirname "$CA_STAMP")"
|
||||||
|
rm -f "$CA_STORE/$CA_PREFIX"*.crt
|
||||||
|
installed=0
|
||||||
|
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[ -n "$f" ] || continue
|
||||||
|
base=$(basename "$f")
|
||||||
|
name="$CA_PREFIX$(ca_normalise_name "$base")"
|
||||||
|
count=$(grep -c -- '-----BEGIN CERTIFICATE-----' "$f" 2>/dev/null || true)
|
||||||
|
[ -n "$count" ] || count=0
|
||||||
|
if [ "$count" -gt 1 ]; then
|
||||||
|
# A corporate trust chain is usually delivered as one PEM holding
|
||||||
|
# root + intermediates. update-ca-certificates handles exactly one
|
||||||
|
# certificate per file, so split it.
|
||||||
|
awk -v out="$CA_STORE/${name%.crt}" '
|
||||||
|
/-----BEGIN CERTIFICATE-----/ { n++; f = out "-" n ".crt" }
|
||||||
|
n > 0 { print > f }
|
||||||
|
' "$f" && installed=$((installed + count))
|
||||||
|
elif [ "$count" -eq 1 ]; then
|
||||||
|
cp -f "$f" "$CA_STORE/$name" && installed=$((installed + 1))
|
||||||
|
else
|
||||||
|
echo "entrypoint: warning — $f holds no PEM certificate (DER is not supported), skipping"
|
||||||
|
fi
|
||||||
|
done <<< "$files"
|
||||||
|
|
||||||
|
chmod 644 "$CA_STORE/$CA_PREFIX"*.crt 2>/dev/null
|
||||||
|
|
||||||
|
if [ "$installed" -eq 0 ]; then
|
||||||
|
echo "entrypoint: warning — no usable certificates found under $CA_SRC"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if update-ca-certificates >/dev/null 2>&1; then
|
||||||
|
echo "entrypoint: installed $installed corporate CA certificate(s) into the system trust store"
|
||||||
|
printf '%s' "$fp" > "$CA_STAMP"
|
||||||
|
else
|
||||||
|
echo "entrypoint: warning — update-ca-certificates failed; corporate certificates may not be trusted"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ca_seed_nssdb
|
||||||
|
}
|
||||||
|
|
||||||
|
install_corporate_ca
|
||||||
|
|
||||||
# ── SSH key setup ──────────────────────────────────────────────────────────
|
# ── SSH key setup ──────────────────────────────────────────────────────────
|
||||||
# Host SSH dir is mounted read-only at /tmp/.host-ssh.
|
# Host SSH dir is mounted read-only at /tmp/.host-ssh.
|
||||||
# Copy to /home/claude/.ssh so we can fix permissions.
|
# Copy to /home/claude/.ssh so we can fix permissions.
|
||||||
@@ -277,7 +438,7 @@ ENV_FILE="$SCHEDULER_DIR/.env"
|
|||||||
: > "$ENV_FILE"
|
: > "$ENV_FILE"
|
||||||
env | while IFS='=' read -r key value; do
|
env | while IFS='=' read -r key value; do
|
||||||
case "$key" in
|
case "$key" in
|
||||||
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER)
|
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE)
|
||||||
# Escape single quotes in value and write as KEY='VALUE'
|
# Escape single quotes in value and write as KEY='VALUE'
|
||||||
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
|
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
|
||||||
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
|
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
|
||||||
|
|||||||
Reference in New Issue
Block a user