From c7725a5b08f2d24e1692c48bc884beeb95285e84 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:15:33 -0400 Subject: [PATCH] Diagnose Node TLS trust failures in Agent CLI --- src/core/errors.ts | 18 ++++++++++++++++++ test/error_hints.test.ts | 10 ++++++++++ test/errors.test.ts | 9 +++++++++ 3 files changed, 37 insertions(+) diff --git a/src/core/errors.ts b/src/core/errors.ts index 1779fc1e..bb28f361 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -153,6 +153,22 @@ export const NETWORK_CODES = new Set([ "UND_ERR_SOCKET", ]); +/** TLS failures are different from offline/network failures. Keep verification on. */ +const TLS_CA_CODES = new Set([ + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "SELF_SIGNED_CERT_IN_CHAIN", +]); + +export function tlsCaHint(err: unknown): string | null { + if (!(err instanceof Error)) return null; + const code = (err.cause as { code?: unknown } | undefined)?.code; + if (typeof code !== "string" || !TLS_CA_CODES.has(code)) return null; + return process.platform === "win32" + ? "TLS certificate trust failed — set NODE_USE_SYSTEM_CA=1 for Aether Agent so Node uses the Windows trust store" + : "TLS certificate trust failed — install the trusted issuer or configure NODE_EXTRA_CA_CERTS"; +} + /** * True when `err` is the client-side cancellation of an in-flight turn * (AbortController fired). Undici surfaces this two ways depending on where @@ -198,6 +214,8 @@ export function httpStatusHint(status: number): string | null { * actionable next step at all. */ export function nonHttpErrorHint(err: unknown): string | null { + const tlsHint = tlsCaHint(err); + if (tlsHint !== null) return tlsHint; if (err instanceof MalformedResponseError) return "retry, or /doctor to check connectivity"; if (err instanceof StreamEventTooLargeError) return "retry, or /doctor to inspect the server stream"; if (err instanceof StreamTimeoutError) return "the stream went quiet - retry, or /doctor to check connectivity"; diff --git a/test/error_hints.test.ts b/test/error_hints.test.ts index e2ad6a77..afa699d9 100644 --- a/test/error_hints.test.ts +++ b/test/error_hints.test.ts @@ -21,6 +21,16 @@ test("network failures point at connectivity", () => { assert.match(hintFor(new Error("connect ECONNREFUSED 1.2.3.4:443"))!, /network/); }); +test("TLS trust failures get a secure CA repair rather than a network hint", () => { + const tls = new TypeError("fetch failed", { + cause: { code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE" }, + }); + const hint = hintFor(tls) ?? ""; + assert.match(hint, /TLS certificate trust failed/); + assert.match(hint, process.platform === "win32" ? /NODE_USE_SYSTEM_CA=1/ : /NODE_EXTRA_CA_CERTS/); + assert.doesNotMatch(hint, /TLS_REJECT_UNAUTHORIZED/); +}); + // LOOP-06 round 3: undici puts the failure code on err.cause.code, not in // the message text, for real fetch failures — errors.errorHint already // checked this via NETWORK_CODES; hintFor only pattern-matched the message diff --git a/test/errors.test.ts b/test/errors.test.ts index fae7d8c3..cea9fda3 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -31,6 +31,15 @@ test("network failures hint at /doctor with the base url", () => { assert.match(h, /offline\?/); }); +test("REPL TLS trust failure gives the same CA repair", () => { + const tls = new TypeError("fetch failed", { + cause: { code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE" }, + }); + const hint = errorHint(tls, BASE) ?? ""; + assert.match(hint, /TLS certificate trust failed/); + assert.match(hint, process.platform === "win32" ? /NODE_USE_SYSTEM_CA=1/ : /NODE_EXTRA_CA_CERTS/); +}); + test("stream timeouts get a retry/doctor hint, matching error_hints.hintFor (LOOP-06 round 2)", () => { // Regression for LOOP-06 round 2: errorHint used to have no branch for // StreamTimeoutError, so it fell through to the generic Error branch (no