Inject the corporate CA certificate into containers
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Behind a TLS-terminating corporate proxy every HTTPS call inside a container fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API requests. There was no mechanism at all: installing the certificate by hand inside a container is lost on Reset and had to be repeated per project. A global CA path in AppSettings with a per-project override on Project, taking either a single certificate file or a directory. It is bind-mounted read-only at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by entrypoint.sh on every start, so it survives recreation, migration and Reset. Four things this gets right that are easy to get wrong: * update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely copied in is ignored in silence. Certificates are renamed, by container_cert_name() in Rust and a mirrored few lines of shell. * The system store only serves curl/git/apt. Node — and so Claude Code itself — needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil (libnss3-tools, added to the image). * Those vars are set from Rust at creation, never exported by the entrypoint — a terminal is a docker exec and sees nothing the entrypoint exported. They are emitted empty when no CA is configured, since docker commit bakes env into the snapshot image. * triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so a CA rotated in at the same location still forces a recreation. Verified end to end against a real container and a self-signed CA: curl, node, python and git all complete a TLS handshake against a server signed by it and all three fail in the same container without it; the env vars are visible from a docker exec session; the store is cleaned when the setting is cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user