Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,22 @@ Supabase coordinates (`supabase_url`, `supabase_anon_key`) and the public gatewa
- Tests that exercise real `login()` must mock `POST /codev-backend/config` if (and only if) the caller also calls `refreshCodevConfig`.
2. **`runUpload` retries once on a "refreshable" error.** `isRefreshableError` (in `src/lib/upload.ts`) is deliberately narrow: `Missing supabase_…` from the cache accessors, or HTTP `401`/`403` from any Supabase or backend fetch. `5xx`, `404`, network errors, and timeouts are NOT retried — refreshing won't help and we'd amplify the outage. Per-file upload errors stay in `summary.errors` and don't trigger the pipeline-level retry. If you change `runSupabaseUpload`'s shape, keep that boundary intact.

## TLS trust (corporate proxies)

Node verifies TLS against its **own bundled Mozilla CA snapshot and never consults the OS trust store** ([tls.rootCertificates](https://nodejs.org/api/tls.html#tlsrootcertificates): "fixed at release time… identical on all supported platforms"). Users behind a TLS-intercepting proxy (Zscaler/Netskope/Fortinet) or HTTPS-scanning AV get every chain re-signed by a corporate root that MDM/GPO installed into the *OS* store — so their browser works and we fail with `fetch failed (self-signed certificate in certificate chain)`.

`src/lib/tls.ts#applySystemCaCertsOnce` fixes this with no user configuration: `tls.getCACertificates("system")` reads the OS store **even without the `--use-system-ca` flag**, so we merge it into the default set. Details that are load-bearing:

- **Merge on failure, never speculatively.** `loggedFetch` runs the request; only if it fails with a cert error does it merge and retry once (`fetchTrustingSystemCa`). The OS-store read is **synchronous** and costs ~20ms on macOS but ~300ms+ on Windows, where it blocks the event loop — an earlier revision merged before the first request and slowed the whole Windows suite by 50–200%, stalling Ink's render timers badly enough to fail three timing-sensitive tests (including `TaskList`, which never fetches). A cert error is a precise signal that the user is one of the affected minority, so paying only then keeps the happy path at exactly zero cost. `tests/lib/log.test.ts` pins that a successful request never touches the store.
- **At most one retry per process.** `applySystemCaCertsOnce` returns null once it has run, so a chain that stays untrusted surfaces its error instead of looping. Replay is safe: a TLS handshake fails before any body is sent, and every call site passes a replayable body (string/URLSearchParams/FormData/Buffer), never a stream — keep it that way.
- **Merge `default` + `system`, never `bundled` + `system`.** `"default"` already folds in `NODE_EXTRA_CA_CERTS`; narrowing it would silently drop the certs of users who fixed this the documented way.
- **Every failure mode is a no-op**, including an empty system store (`setDefaultCACertificates` is then never called, keeping Node's behavior byte-identical). Trust config isn't ours to have opinions about, and a bad merge would break users whose certs already work.
- The APIs need Node ≥22.19/24.5 (our floor is 22.5), hence `tlsApi.supported()`. They're accessed off the default import, never destructured — a named ESM import of a builtin export that doesn't exist is a link-time error on older Node.

`describeNetworkError` unwraps Node's bare `fetch failed` (the real reason hides on `err.cause`) and appends a remedy for cert codes. It exists because **Node's own `--use-system-ca` hint pointedly excludes `SELF_SIGNED_CERT_IN_CHAIN`** — `crypto_common.cc` gates it on `DEPTH_ZERO_SELF_SIGNED_CERT` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE` / `UNABLE_TO_GET_ISSUER_CERT` only, so the corporate-proxy case, the one the hint most exists for, is the one that prints nothing. If a cert error survives the merge, the root isn't in the OS store either, so the hint names `NODE_EXTRA_CA_CERTS` rather than `--use-system-ca`, which would be a dead end. Keep `certHint` **pure** — reading the OS store to word a sentence would put that same Windows stall on the error path, and the retry has already read it.

Note the blast radius: this only covers **our own** process. `npm i -g` during install and the agents themselves are separate processes behind the same proxy and need `NODE_EXTRA_CA_CERTS` / npm's `cafile` of their own.

## Diagnostic logging

`~/.codev-hub` has two log homes — don't mix them up:
Expand Down
3 changes: 2 additions & 1 deletion src/LoginApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
saveSkillhubCookie,
} from "@/lib/auth.js";
import { type SkillhubUser, skillhubSignIn } from "@/lib/skillhub.js";
import { describeNetworkError } from "@/lib/tls.js";

type Phase = "preparing" | "login" | "refreshing-config" | "done";

Expand Down Expand Up @@ -98,7 +99,7 @@ function AdminLoginApp({
saveSkillhubCookie(cookie);
handleDone(u);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = describeNetworkError(err);
setError(msg);
// Reject waitUntilExit so the dispatcher exits non-zero; hold the
// error frame briefly so it's readable first.
Expand Down
3 changes: 2 additions & 1 deletion src/components/AdminLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Spinner from "ink-spinner";
import { useCallback, useRef, useState } from "react";
import { saveSkillhubCookie } from "@/lib/auth.js";
import { type SkillhubUser, skillhubSignIn } from "@/lib/skillhub.js";
import { describeNetworkError } from "@/lib/tls.js";

interface AdminLoginProps {
onDone: (user: SkillhubUser) => void;
Expand Down Expand Up @@ -56,7 +57,7 @@ export function AdminLogin({
saveSkillhubCookie(cookie);
onDone(user);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = describeNetworkError(err);
const used = attemptsRef.current + 1;
attemptsRef.current = used;
setAttempts(used);
Expand Down
16 changes: 4 additions & 12 deletions src/components/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { PasteBackPrompt, usePasteBack } from "@/components/PasteBack.js";
import { type AuthData, login } from "@/lib/auth.js";
import { clipboard } from "@/lib/clipboard.js";
import { describeNetworkError } from "@/lib/tls.js";

interface LoginProps {
onDone: (auth: AuthData) => void;
Expand Down Expand Up @@ -71,18 +72,9 @@ export function Login({ onDone, fallbackDelayMs = 3000 }: LoginProps) {
onDone(auth);
})
.catch((err: Error) => {
// Node's built-in fetch throws `TypeError: fetch failed` for any
// network-layer failure and stashes the real reason (DNS, TLS,
// proxy interception, etc.) on `err.cause`. Surface it so users
// can self-diagnose instead of staring at a bare "fetch failed".
const cause = err.cause;
const causeMsg =
cause instanceof Error
? cause.message
: cause !== undefined
? String(cause)
: "";
setError(causeMsg ? `${err.message} (${causeMsg})` : err.message);
// Unwraps Node's bare `fetch failed` to the real reason (DNS, TLS,
// proxy interception) and appends a remedy for certificate failures.
setError(describeNetworkError(err));
});
}, [addLog, onDone, attempt]);

Expand Down
36 changes: 35 additions & 1 deletion src/lib/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,40 @@ import {
import { join } from "node:path";
import { VERSION } from "@/lib/const.js";
import { cliLogsDir } from "@/lib/paths.js";
import { applySystemCaCertsOnce, isCertError } from "@/lib/tls.js";

// Runs a request, and if it fails because the certificate chain isn't trusted,
// merges the OS trust store into Node's defaults and tries once more.
//
// Node ignores the OS store, so a user behind a TLS-intercepting proxy fails
// every request while their browser works (see lib/tls.ts). Recovering *here*,
// on the failure, rather than merging up-front, is what keeps the cost off the
// happy path: the OS-store read is synchronous and blocks the event loop for
// ~300ms on Windows, which is enough to stall Ink's render timers.
//
// At most one retry per process: applySystemCaCertsOnce returns null once it has
// run, so a chain that stays untrusted surfaces its error instead of looping.
// Safe to replay — a TLS handshake fails before any body is sent, and every
// call site passes a replayable body (string/URLSearchParams/FormData/Buffer),
// never a stream.
async function fetchTrustingSystemCa(
input: string | URL,
init: RequestInit | undefined,
endpoint: string,
): Promise<Response> {
try {
return await fetch(input, init);
} catch (err) {
if (!isCertError(err)) throw err;
const ca = applySystemCaCertsOnce();
if (ca?.status !== "merged") throw err;
logInfo("certificate chain untrusted; retrying with the OS CA store", {
action: "http.request",
extra: { endpoint, ca_system_count: ca.systemCount },
});
return await fetch(input, init);
}
}

// CoDev's local diagnostic log: one Elastic-Common-Schema NDJSON document per
// line, written to ~/.codev-hub/logs/codev-YYYYMMDD.ndjson (UTC date). The files
Expand Down Expand Up @@ -276,7 +310,7 @@ export async function loggedFetch(
});
const startedAt = Date.now();
try {
const res = await fetch(input, init);
const res = await fetchTrustingSystemCa(input, init, endpoint);
const durationMs = Date.now() - startedAt;
if (res.ok) {
logDebug(`http ${method} ${endpoint} → ${res.status}`, {
Expand Down
162 changes: 162 additions & 0 deletions src/lib/tls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import tls from "node:tls";

// Node verifies TLS against its own bundled Mozilla CA snapshot and never
// consults the OS trust store (tls.rootCertificates: "fixed at release time…
// identical on all supported platforms"). On a machine behind a TLS-intercepting
// proxy (Zscaler/Netskope/Fortinet) or HTTPS-scanning AV, every chain is
// re-signed by a corporate root that MDM/GPO installed into the *OS* store — so
// browsers work and we fail with `fetch failed (self-signed certificate in
// certificate chain)`.
//
// tls.getCACertificates("system") reads that OS store even without the
// --use-system-ca flag, so merging it into the default set fixes those users
// with no configuration on their side.
//
// Indirection so tests can simulate a Node that predates these APIs (mirrors
// `spawner` in run.ts / `browserOpener` in auth.ts). Not destructured at import:
// on Node < 22.19 these are `undefined`, and a named ESM import of a missing
// builtin export is a link-time error.
export const tlsApi = {
getCACertificates: (type: string): string[] =>
(
tls as unknown as {
getCACertificates?: (t: string) => string[];
}
).getCACertificates?.(type) ?? [],
setDefaultCACertificates: (certs: string[]): void => {
(
tls as unknown as {
setDefaultCACertificates?: (c: string[]) => void;
}
).setDefaultCACertificates?.(certs);
},
supported: (): boolean =>
typeof (tls as unknown as Record<string, unknown>).getCACertificates ===
"function" &&
typeof (tls as unknown as Record<string, unknown>)
.setDefaultCACertificates === "function",
};

export type CaMergeStatus = "merged" | "unsupported" | "empty" | "failed";

export interface CaMergeResult {
status: CaMergeStatus;
/** How many certs the OS store contributed. */
systemCount: number;
error?: string;
}

let attempted = false;

// Merges the OS trust store into Node's default CA set. Runs at most once per
// process; every later call returns null so a caller can't retry forever.
//
// Deliberately called only *after* a certificate failure, never speculatively.
// The OS-store read is synchronous and costs ~20ms on macOS but ~300ms+ on
// Windows, where it blocks the event loop — enough to stall Ink's render timers.
// Since the users who need this are the minority behind an intercepting proxy,
// and a cert failure is a precise signal that they're one of them, paying on
// failure keeps the happy path at exactly zero cost.
//
// Returns null when a previous call already attempted the merge — the caller
// must not retry the request again, or a permanently untrusted chain would loop.
//
// Best-effort by construction: TLS trust is not this CLI's job to have opinions
// about, and any failure here must leave Node's default behavior exactly as it
// was rather than break a user whose certs already work.
export function applySystemCaCertsOnce(): CaMergeResult | null {
if (attempted) return null;
attempted = true;
return mergeSystemCaCerts();
}

function mergeSystemCaCerts(): CaMergeResult {
// Node < 22.19 / < 24.5. Those users need --use-system-ca (22.15+) or
// NODE_EXTRA_CA_CERTS; describeNetworkError tells them so.
if (!tlsApi.supported()) return { status: "unsupported", systemCount: 0 };
try {
const system = tlsApi.getCACertificates("system");
if (system.length === 0) return { status: "empty", systemCount: 0 };
// "default" already folds in NODE_EXTRA_CA_CERTS, so a user who fixed this
// the documented way keeps working — don't narrow this to the bundled set.
const defaults = tlsApi.getCACertificates("default");
tlsApi.setDefaultCACertificates([...defaults, ...system]);
return { status: "merged", systemCount: system.length };
} catch (err) {
return {
status: "failed",
systemCount: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}

// Test-only: forget that the merge ran so each case starts clean.
export function resetSystemCaCertsCache(): void {
attempted = false;
}

// OpenSSL verify failures that mean "I don't trust this chain", as opposed to
// DNS/connection/timeout errors. Node surfaces them on `err.cause.code`.
const CERT_ERROR_CODES = new Set([
"SELF_SIGNED_CERT_IN_CHAIN",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"UNABLE_TO_GET_ISSUER_CERT",
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"CERT_UNTRUSTED",
"SELF_SIGNED_CERT_IN_CHAIN_ERR",
]);

// True when a thrown fetch error bottoms out in "I don't trust this chain".
// Node's fetch hides the reason on `err.cause`, so unwrap before matching.
export function isCertError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const cause = err.cause;
if (!(cause instanceof Error)) return false;
const code = (cause as NodeJS.ErrnoException).code;
return code !== undefined && CERT_ERROR_CODES.has(code);
}

// Pure: reading the OS store here would put a ~300ms Windows stall on the error
// path just to word a sentence. By the time this runs, loggedFetch has already
// tried the merge and retried, so "not in your store either" is accurate.
function certHint(): string {
if (!tlsApi.supported()) {
return (
`This looks like a TLS-intercepting proxy or antivirus. Node ${process.version} ` +
"can't read your system certificate store — upgrade to Node 22.15+ and re-run, " +
"or point NODE_EXTRA_CA_CERTS at your organization's root CA (PEM file)."
);
}
// The merge ran and the chain still isn't trusted, so the intercepting root
// isn't in the OS store either — pointing at --use-system-ca would be a dead
// end. Only the explicit PEM can help.
return (
"This looks like a TLS-intercepting proxy or antivirus, and its root CA isn't " +
"in your system certificate store. Ask IT for the root CA and point " +
"NODE_EXTRA_CA_CERTS at it (a PEM file), then re-run."
);
}

// Renders a network error for humans.
//
// Node's fetch throws a bare `TypeError: fetch failed` and hides the real reason
// (DNS, TLS, proxy) on `err.cause` — so unwrap it. For certificate failures,
// append the remedy: Node only volunteers its own --use-system-ca hint for
// DEPTH_ZERO_SELF_SIGNED_CERT / UNABLE_TO_VERIFY_LEAF_SIGNATURE /
// UNABLE_TO_GET_ISSUER_CERT, which pointedly excludes SELF_SIGNED_CERT_IN_CHAIN
// — the corporate-proxy case this exists for. Those users get a dead-end string
// unless we say something.
export function describeNetworkError(err: unknown): string {
if (!(err instanceof Error)) return String(err);
const cause: unknown = err.cause;
const causeMsg =
cause instanceof Error
? cause.message
: cause !== undefined
? String(cause)
: "";
const base = causeMsg ? `${err.message} (${causeMsg})` : err.message;
return isCertError(err) ? `${base}\n${certHint()}` : base;
}
83 changes: 83 additions & 0 deletions tests/lib/log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from "@/lib/log.js";
import { execAsync } from "@/lib/npm.js";
import { runAgent, spawner as runSpawner } from "@/lib/run.js";
import { resetSystemCaCertsCache, tlsApi } from "@/lib/tls.js";

let tempDir: string;
let logDir: string;
Expand Down Expand Up @@ -336,6 +337,88 @@ describe("pruneLogs", () => {
});
});

describe("loggedFetch system-CA recovery", () => {
function certFailure(): Error {
const err = new TypeError("fetch failed");
const cause: NodeJS.ErrnoException = new Error(
"self-signed certificate in certificate chain",
);
cause.code = "SELF_SIGNED_CERT_IN_CHAIN";
err.cause = cause;
return err;
}

// This file's top-level afterEach doesn't restore mocks, so the fetch/tls
// spies below would otherwise leak into the next case's call counts.
afterEach(() => {
resetSystemCaCertsCache();
vi.restoreAllMocks();
});

// The regression that broke Windows CI: reading the OS store is a synchronous
// ~300ms stall there, so a request that succeeds must never touch it.
test("does not read the OS store on the happy path", async () => {
const get = vi.spyOn(tlsApi, "getCACertificates");
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok"));

await loggedFetch("backend.config", "https://x.test/");

expect(get).not.toHaveBeenCalled();
});

test("merges the OS store and retries once after a cert failure", async () => {
vi.spyOn(tlsApi, "getCACertificates").mockImplementation((type) =>
type === "system" ? ["sys"] : ["def"],
);
vi.spyOn(tlsApi, "setDefaultCACertificates").mockImplementation(() => {});
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValueOnce(certFailure())
.mockResolvedValueOnce(new Response("ok", { status: 200 }));

const res = await loggedFetch("sso.token", "https://x.test/");

expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});

test("gives up after one retry when the chain stays untrusted", async () => {
vi.spyOn(tlsApi, "getCACertificates").mockImplementation((type) =>
type === "system" ? ["sys"] : ["def"],
);
vi.spyOn(tlsApi, "setDefaultCACertificates").mockImplementation(() => {});
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(certFailure());

await expect(loggedFetch("sso.token", "https://x.test/")).rejects.toThrow(
"fetch failed",
);
expect(fetchSpy).toHaveBeenCalledTimes(2);

// A second request must not re-read the store or retry again — the merge
// already happened and didn't help.
fetchSpy.mockClear();
await expect(loggedFetch("sso.token", "https://x.test/")).rejects.toThrow(
"fetch failed",
);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});

test("does not retry a non-certificate failure", async () => {
const err = new TypeError("fetch failed");
const cause: NodeJS.ErrnoException = new Error("getaddrinfo ENOTFOUND");
cause.code = "ENOTFOUND";
err.cause = cause;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(err);

await expect(loggedFetch("sso.token", "https://x.test/")).rejects.toThrow(
"fetch failed",
);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});

describe("loggedFetch", () => {
test("passes through and writes start + completion documents", async () => {
initLogging("upload", [], { installProcessHooks: false });
Expand Down
Loading
Loading