ai-lens 0.8.86 → 0.8.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.commithash CHANGED
@@ -1 +1 @@
1
- a628f26
1
+ d547508
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
4
4
 
5
+ ## 0.8.88 — 2026-06-11
6
+ - fix: events now reach the server from behind corporate antivirus/proxy TLS inspection — on a certificate-trust error the sender automatically retries once trusting the OS certificate store (Node 22.15+; opt out with `AI_LENS_TLS_STRICT=1`)
7
+ - feat: `ai-lens status` now diagnoses TLS failures — it shows which certificate authority actually served the connection and names the likely interceptor instead of a bare error code
8
+ - improve: TLS send failures in `~/.ai-lens/sender.log` now include a self-heal status and a fix hint, not just an error counter
9
+
10
+ ## 0.8.87 — 2026-06-11
11
+ - fix: the client version reported to the server (`X-Client-Version`) now describes the code that actually ran. Previously a repo-path install (`init --use-repo-path`) reported the version of the old copied client in `~/.ai-lens/client/` forever, making up-to-date setups look ancient in server logs and diagnostics.
12
+
5
13
  ## 0.8.86 — 2026-06-11
6
14
  - fix: `ai-lens import claude-code` now respects your configured projects filter (`AI_LENS_PROJECTS` / the `projects` setting) by default, the same way live capture does — previously, without an explicit `--projects` flag it imported your entire local history. Pass `--projects all` to deliberately import everything, or `--projects A,B` to use a different list. The import offer inside `ai-lens init` follows the same rule.
7
15
 
package/cli/status.js CHANGED
@@ -3,6 +3,9 @@ import { execSync, spawnSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
  import { homedir, release as osRelease, arch as osArch } from 'node:os';
5
5
  import { randomUUID } from 'node:crypto';
6
+ import tls from 'node:tls';
7
+
8
+ import { TLS_TRUST_CODES, tlsCodeOf, tlsVerdictSummary, issuerName } from '../client/tls-trust.js';
6
9
 
7
10
  import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig, analyzeToolHooks, checkHooksDisabled, verifyCodexHookTrust, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
8
11
  import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, LAST_STATUS_REPORT_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
@@ -651,7 +654,13 @@ function checkSenderLog() {
651
654
  if (tsMs > lastErrorMs) {
652
655
  lastErrorMs = tsMs;
653
656
  lastError = entry.ts;
654
- lastErrorCode = entry.cause?.code || entry.code || entry.error || null;
657
+ // Prefer a TLS-trust code wherever it hides in the (possibly nested) log shape —
658
+ // cause.innerCode / fallbackCode are populated by the sender's TLS path. Otherwise
659
+ // fall back to the first non-null code, as before.
660
+ const codeCandidates = [entry.cause?.code, entry.cause?.innerCode, entry.fallbackCode, entry.code, entry.error];
661
+ lastErrorCode = codeCandidates.find((c) => c && TLS_TRUST_CODES.has(c))
662
+ || codeCandidates.find((c) => c != null)
663
+ || null;
655
664
  }
656
665
  if (entry.msg === 'failed' && now - tsMs <= DAY_MS) recentFailed++;
657
666
  }
@@ -693,6 +702,7 @@ function checkSenderLog() {
693
702
  return {
694
703
  ok: !activeErrors,
695
704
  summary,
705
+ lastErrorCode,
696
706
  detail: `Log: ${LOG_PATH}\nTotal entries: ${lines.length}\nCounters: sent_ok=${sentOk}, failed=${failedCount} (${recentFailed} in 24h), rollbacks=${rollbackCount}\nLast send: ${lastSend || '(never)'}\nLast error: ${lastError || '(none)'}${lastErrorCode ? ` (${lastErrorCode})` : ''}\nState: ${state}\nLast 20 entries:\n${last20.join('\n')}`,
697
707
  };
698
708
  }
@@ -854,6 +864,7 @@ async function checkServer(serverUrl) {
854
864
  const PER_ATTEMPT_TIMEOUT = 5000;
855
865
  const codes = [];
856
866
  const attemptLog = [];
867
+ let lastErr;
857
868
  for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
858
869
  const start = Date.now();
859
870
  try {
@@ -874,8 +885,11 @@ async function checkServer(serverUrl) {
874
885
  detail: `URL: ${url}\nStatus: ${res.status}\nLatency: ${latency}ms\nResponse: ${body}`,
875
886
  };
876
887
  } catch (err) {
888
+ lastErr = err;
877
889
  const latency = Date.now() - start;
878
- const code = err.code || err.cause?.code || err.message;
890
+ // Same cause-chain walker as errorCode below, so the printed code never disagrees
891
+ // with the code that triggers the TLS diagnostic.
892
+ const code = tlsCodeOf(err) || err.message;
879
893
  codes.push(code);
880
894
  attemptLog.push(`#${attempt}: ${code} (${latency}ms)`);
881
895
  if (attempt < ATTEMPTS) await new Promise(r => setTimeout(r, 1000));
@@ -883,11 +897,80 @@ async function checkServer(serverUrl) {
883
897
  }
884
898
  return {
885
899
  ok: false,
900
+ // errorCode uses the shared cause-chain walker so a TLS-trust code nested under
901
+ // err.cause.cause is still surfaced for the TLS diagnostic trigger.
902
+ errorCode: tlsCodeOf(lastErr),
886
903
  summary: `unreachable (${codes[codes.length - 1]}, ${ATTEMPTS} attempts)`,
887
904
  detail: `URL: ${url}\nUnreachable after ${ATTEMPTS} attempts:\n${attemptLog.join('\n')}`,
888
905
  };
889
906
  }
890
907
 
908
+ // --- TLS diagnostic (Layer 2) -------------------------------------------------
909
+ // Pure helpers are named-exported so test/cli/status.test.js can exercise them without
910
+ // running the full status() flow or a live TLS endpoint.
911
+
912
+ /** Parse serverUrl into a handshake target, or a skip reason for non-https / bad config. */
913
+ export function tlsDiagTargetFromUrl(serverUrl) {
914
+ let u;
915
+ try { u = new URL(serverUrl); }
916
+ catch { return { skip: 'unparseable serverUrl' }; }
917
+ if (u.protocol !== 'https:') return { skip: `non-https (${u.protocol})` };
918
+ return { host: u.hostname, port: Number(u.port) || 443 };
919
+ }
920
+
921
+ /** Run the diagnostic only when a TLS chain-trust code surfaced (server probe or sender log). */
922
+ export function shouldRunTlsDiag({ errorCode, lastErrorCode } = {}) {
923
+ return TLS_TRUST_CODES.has(errorCode) || TLS_TRUST_CODES.has(lastErrorCode);
924
+ }
925
+
926
+ /**
927
+ * Unauthenticated TLS handshake to read the cert actually served to this machine and name the
928
+ * interceptor. Never throws or hangs — every path resolves a graceful informational result.
929
+ */
930
+ async function checkServerTls(serverUrl) {
931
+ const target = tlsDiagTargetFromUrl(serverUrl);
932
+ if (target.skip) {
933
+ return { ok: null, summary: `skipped — ${target.skip}`, detail: `TLS diagnostic skipped: ${target.skip}` };
934
+ }
935
+ const TIMEOUT = 5000;
936
+ const where = `${target.host}:${target.port}`;
937
+ return await new Promise((resolve) => {
938
+ let settled = false;
939
+ const done = (result) => { if (!settled) { settled = true; resolve(result); } };
940
+ let socket;
941
+ try {
942
+ socket = tls.connect({ host: target.host, port: target.port, servername: target.host, rejectUnauthorized: false }, () => {
943
+ const cert = socket.getPeerCertificate();
944
+ try { socket.destroy(); } catch {}
945
+ if (!cert || Object.keys(cert).length === 0) {
946
+ done({ ok: null, summary: 'no peer certificate', detail: `Host: ${where}\nNo peer certificate returned` });
947
+ return;
948
+ }
949
+ const subject = cert.subject || {};
950
+ const verdict = tlsVerdictSummary(cert);
951
+ const detail = [
952
+ `Host: ${where}`,
953
+ `Subject CN: ${subject.CN || '(none)'}`,
954
+ `Issuer: ${issuerName(cert)}`,
955
+ `Valid: ${cert.valid_from || '?'} → ${cert.valid_to || '?'}`,
956
+ `Verdict: ${verdict}`,
957
+ ].join('\n');
958
+ done({ ok: null, summary: verdict, detail });
959
+ });
960
+ socket.setTimeout(TIMEOUT, () => {
961
+ try { socket.destroy(); } catch {}
962
+ done({ ok: null, summary: 'handshake timed out', detail: `Host: ${where}\nTLS handshake timed out after ${TIMEOUT}ms` });
963
+ });
964
+ socket.on('error', (err) => {
965
+ try { socket.destroy(); } catch {}
966
+ done({ ok: null, summary: `handshake error: ${err.code || err.message}`, detail: `Host: ${where}\nError: ${err.message}` });
967
+ });
968
+ } catch (err) {
969
+ done({ ok: null, summary: `handshake error: ${err.message}`, detail: `Host: ${where}\nError: ${err.message}` });
970
+ }
971
+ });
972
+ }
973
+
891
974
  async function checkToken(serverUrl, authToken) {
892
975
  if (!serverUrl || !authToken) {
893
976
  const missing = !serverUrl ? 'server URL' : 'auth token';
@@ -1378,7 +1461,8 @@ export default async function status({ report = false } = {}) {
1378
1461
  printLine('Capture test', checkCaptureRun(toolsWithProject));
1379
1462
 
1380
1463
  // 9. Sender log
1381
- printLine('Sender log', checkSenderLog());
1464
+ const senderLogResult = checkSenderLog();
1465
+ printLine('Sender log', senderLogResult);
1382
1466
 
1383
1467
  // 10. Capture drops
1384
1468
  printLine('Capture drops', checkCaptureLog());
@@ -1392,6 +1476,13 @@ export default async function status({ report = false } = {}) {
1392
1476
  const serverResult = await checkServer(serverUrl);
1393
1477
  printLine('Server', serverResult);
1394
1478
 
1479
+ // 12b. TLS diagnostic — only when a TLS chain-trust code surfaced (server probe or sender log).
1480
+ // Interception is often intermittent, so the live handshake may currently pass — we still
1481
+ // report the served issuer so the verdict is on record.
1482
+ if (shouldRunTlsDiag({ errorCode: serverResult.errorCode, lastErrorCode: senderLogResult.lastErrorCode })) {
1483
+ printLine('TLS diagnostic', await checkServerTls(serverUrl));
1484
+ }
1485
+
1395
1486
  // 13. Token validity
1396
1487
  const authToken = configResult.authToken || readLensConfig().authToken;
1397
1488
  const tokenResult = await checkToken(serverUrl, authToken);
package/client/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, appendFileSync, readFileSync, writeFileSync, renameSync, realpathSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
2
+ import { join, resolve, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { homedir } from 'node:os';
4
5
  import * as childProcess from 'node:child_process';
5
6
 
@@ -116,12 +117,41 @@ export function getAuthToken() {
116
117
  return process.env.AI_LENS_AUTH_TOKEN || loadConfig().authToken || null;
117
118
  }
118
119
 
120
+ let _clientVersionCache = null;
121
+ /** Test hook: reset the per-process getClientVersion cache. */
122
+ export function _clearClientVersionCache() { _clientVersionCache = null; }
123
+
124
+ /**
125
+ * Identify the RUNNING client code — not the installed copy. Resolution order:
126
+ * 1. version.json next to THIS module ({version, commit, packageRoot} written
127
+ * by init at copy time, so it describes exactly these files) — the copy
128
+ * install case (~/.ai-lens/client/);
129
+ * 2. the surrounding ai-lens package (repo checkout or npx cache): version
130
+ * from ../package.json, commit from `git rev-parse` (live checkout) with
131
+ * the published .commithash file as fallback (npx cache has no .git).
132
+ * It used to always read ~/.ai-lens/client/version.json, so a --use-repo-path
133
+ * install reported the stale copied version forever and X-Client-Version lied
134
+ * about what code actually ran (a 0.8.44 header over current-code events).
135
+ */
119
136
  export function getClientVersion() {
137
+ if (_clientVersionCache) return _clientVersionCache;
138
+ const here = dirname(fileURLToPath(import.meta.url));
120
139
  try {
121
- return JSON.parse(readFileSync(join(CLIENT_INSTALL_DIR, 'version.json'), 'utf-8'));
122
- } catch {
123
- return { version: 'unknown', commit: 'unknown' };
124
- }
140
+ _clientVersionCache = JSON.parse(readFileSync(join(here, 'version.json'), 'utf-8'));
141
+ return _clientVersionCache;
142
+ } catch { /* no sibling version.json — running from the package, not a copy */ }
143
+ try {
144
+ const packageRoot = resolve(here, '..');
145
+ const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'));
146
+ if (pkg?.name === 'ai-lens' && pkg.version) {
147
+ let commit = null;
148
+ try { commit = _gitRunner(['-C', packageRoot, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf-8', timeout: 3000 }).trim() || null; } catch { /* not a checkout */ }
149
+ if (!commit) { try { commit = readFileSync(join(packageRoot, '.commithash'), 'utf-8').trim() || null; } catch { /* no .commithash */ } }
150
+ _clientVersionCache = { version: pkg.version, commit: commit || 'unknown', packageRoot };
151
+ return _clientVersionCache;
152
+ }
153
+ } catch { /* unreadable package.json */ }
154
+ return { version: 'unknown', commit: 'unknown' };
125
155
  }
126
156
 
127
157
  export function getGitIdentity(cwd) {
package/client/sender.js CHANGED
@@ -18,6 +18,14 @@ import { join, dirname } from 'node:path';
18
18
  import { randomUUID } from 'node:crypto';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { spawn } from 'node:child_process';
21
+ import https from 'node:https';
22
+ import {
23
+ isTlsTrustError,
24
+ tlsCodeOf,
25
+ tlsSelfHealEnabled,
26
+ systemCaAvailable,
27
+ loadTrustBundle,
28
+ } from './tls-trust.js';
21
29
  import {
22
30
  ensureDataDir,
23
31
  PENDING_DIR,
@@ -619,10 +627,7 @@ export function isTransientFetchError(err) {
619
627
  return false;
620
628
  }
621
629
 
622
- async function postEventsOnce(serverUrl, events, identity, authToken) {
623
- const body = JSON.stringify(events);
624
- const url = `${serverUrl}/api/events`;
625
-
630
+ function buildHeaders(identity, authToken) {
626
631
  const { version: clientVersion, commit: clientCommit } = getClientVersion();
627
632
  const headers = {
628
633
  'Content-Type': 'application/json',
@@ -632,6 +637,13 @@ async function postEventsOnce(serverUrl, events, identity, authToken) {
632
637
  if (identity.email) headers['X-Developer-Git-Email'] = identity.email;
633
638
  if (identity.name) headers['X-Developer-Name'] = encodeURIComponent(identity.name);
634
639
  if (authToken) headers['X-Auth-Token'] = authToken;
640
+ return headers;
641
+ }
642
+
643
+ async function postEventsOnce(serverUrl, events, identity, authToken) {
644
+ const body = JSON.stringify(events);
645
+ const url = `${serverUrl}/api/events`;
646
+ const headers = buildHeaders(identity, authToken);
635
647
 
636
648
  const res = await fetch(url, {
637
649
  method: 'POST',
@@ -648,6 +660,49 @@ async function postEventsOnce(serverUrl, events, identity, authToken) {
648
660
  throw new Error(`Server responded ${res.status}: ${data}`);
649
661
  }
650
662
 
663
+ /**
664
+ * Fallback POST that trusts the OS certificate store (Node's bundled roots + system store +
665
+ * NODE_EXTRA_CA_CERTS). Used ONCE when the primary fetch() fails with a TLS chain-trust error
666
+ * (corporate proxy / antivirus interception). Built on node:https so we can pass a custom CA
667
+ * without adding undici as a dependency. A raw https.request has no AbortSignal equivalent, so
668
+ * the 30s budget is enforced explicitly via req.setTimeout.
669
+ */
670
+ function postEventsOnceSystemCA(serverUrl, events, identity, authToken) {
671
+ const body = JSON.stringify(events);
672
+ const url = new URL(`${serverUrl}/api/events`);
673
+ const headers = { ...buildHeaders(identity, authToken), 'Content-Length': Buffer.byteLength(body) };
674
+ const agent = new https.Agent({ ca: loadTrustBundle(), keepAlive: false });
675
+
676
+ return new Promise((resolve, reject) => {
677
+ const req = https.request(
678
+ {
679
+ hostname: url.hostname,
680
+ port: url.port || 443,
681
+ path: url.pathname + url.search,
682
+ method: 'POST',
683
+ headers,
684
+ agent,
685
+ },
686
+ (res) => {
687
+ let data = '';
688
+ res.setEncoding('utf8');
689
+ res.on('data', (c) => { data += c; });
690
+ res.on('end', () => {
691
+ if (res.statusCode >= 200 && res.statusCode < 300) {
692
+ try { resolve(JSON.parse(data)); }
693
+ catch (e) { reject(new Error(`Invalid JSON response: ${e.message}`)); }
694
+ } else {
695
+ reject(new Error(`Server responded ${res.statusCode}: ${data}`));
696
+ }
697
+ });
698
+ },
699
+ );
700
+ req.on('error', reject);
701
+ req.setTimeout(30_000, () => req.destroy(new Error('aborted due to timeout')));
702
+ req.end(body);
703
+ });
704
+ }
705
+
651
706
  /**
652
707
  * POST events to the server with retries on transient transport errors.
653
708
  * Retries up to POST_MAX_ATTEMPTS - 1 times with POST_RETRY_BACKOFF_MS delays.
@@ -660,6 +715,7 @@ async function postEventsOnce(serverUrl, events, identity, authToken) {
660
715
  export async function postEvents(serverUrl, events, identity, authToken, opts = {}) {
661
716
  const { lockPath } = opts;
662
717
  let lastErr;
718
+ let triedSystemCa = false;
663
719
  for (let attempt = 0; attempt < POST_MAX_ATTEMPTS; attempt++) {
664
720
  if (attempt > 0) {
665
721
  await new Promise(r => setTimeout(r, POST_RETRY_BACKOFF_MS[attempt - 1]));
@@ -669,6 +725,29 @@ export async function postEvents(serverUrl, events, identity, authToken, opts =
669
725
  return await postEventsOnce(serverUrl, events, identity, authToken);
670
726
  } catch (err) {
671
727
  lastErr = err;
728
+ // TLS chain-trust failure (corporate proxy / antivirus interception): retry ONCE
729
+ // trusting the OS cert store. Only as a fallback after the strict fetch() failed, only
730
+ // for our fixed serverUrl, at most once per call.
731
+ if (!triedSystemCa && tlsSelfHealEnabled() && isTlsTrustError(err) && systemCaAvailable()) {
732
+ triedSystemCa = true;
733
+ const code = tlsCodeOf(err);
734
+ log({ msg: 'tls-self-heal-attempt', code });
735
+ try {
736
+ const result = await postEventsOnceSystemCA(serverUrl, events, identity, authToken);
737
+ log({ msg: 'recovered-via-system-ca', code, events: events.length });
738
+ return result;
739
+ } catch (e2) {
740
+ // The fallback handshake SUCCEEDED but the request itself failed (401, 5xx,
741
+ // timeout): e2 is the real story — surface it so e.g. the auth-failed hint
742
+ // fires instead of a misleading TLS verdict.
743
+ if (!isTlsTrustError(e2)) throw e2;
744
+ // The fallback handshake ALSO failed at the trust layer: throw the ORIGINAL
745
+ // error so isTlsTrustError stays true downstream (status diagnostic + log).
746
+ err.tlsSelfHeal = 'failed';
747
+ err.fallbackCode = tlsCodeOf(e2);
748
+ throw err; // NOT e2; do not fall through to transient-retry handling
749
+ }
750
+ }
672
751
  if (!isTransientFetchError(err)) throw err;
673
752
  const isLastAttempt = attempt === POST_MAX_ATTEMPTS - 1;
674
753
  if (isLastAttempt) break; // don't log a retry that will never happen
@@ -804,7 +883,21 @@ async function main() {
804
883
  const errorDetail = cause
805
884
  ? { message: cause.message, code: cause.code, ...(cause.cause ? { inner: cause.cause.message, innerCode: cause.cause.code } : {}) }
806
885
  : undefined;
807
- log({ msg: 'failed', error: err.message, ...(errorDetail ? { cause: errorDetail } : {}), sent: sentEventIds.size, unsent: unsentIds.size, server: serverUrl });
886
+ // TLS chain-trust failures (corporate proxy / antivirus interception) carry a self-heal
887
+ // status + fix hint, so the fleet view (Axiom) and `ai-lens status` can name and resolve it.
888
+ const tlsFields = isTlsTrustError(err)
889
+ ? {
890
+ tlsTrust: true,
891
+ selfHeal: err.tlsSelfHeal // 'failed' (fallback ran + threw)
892
+ ? err.tlsSelfHeal
893
+ : !tlsSelfHealEnabled() ? 'disabled' // AI_LENS_TLS_STRICT=1
894
+ : !systemCaAvailable() ? 'unavailable' // old Node (no getCACertificates)
895
+ : 'failed',
896
+ ...(err.fallbackCode ? { fallbackCode: err.fallbackCode } : {}),
897
+ hint: 'set NODE_OPTIONS=--use-system-ca or update ai-lens client',
898
+ }
899
+ : {};
900
+ log({ msg: 'failed', error: err.message, ...(errorDetail ? { cause: errorDetail } : {}), ...tlsFields, sent: sentEventIds.size, unsent: unsentIds.size, server: serverUrl });
808
901
  if (err.message.includes('401')) {
809
902
  log({ msg: 'auth-failed', error: 'Token invalid or revoked. Run: npx -y ai-lens init' });
810
903
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * AI Lens — TLS trust helpers (shared by client/sender.js and cli/status.js)
3
+ *
4
+ * Behind corporate TLS inspection (antivirus / proxy that re-signs HTTPS with its own CA,
5
+ * trusted by the OS store but NOT in Node's bundled CA list), `fetch()` fails with
6
+ * UNABLE_TO_VERIFY_LEAF_SIGNATURE and the sender's POST silently rolls back. These helpers
7
+ * let the sender self-heal (retry trusting the OS cert store) and let `status` name the
8
+ * interceptor instead of printing a bare error code.
9
+ */
10
+
11
+ import tls from 'node:tls';
12
+
13
+ // Error codes that mean "I couldn't build a trusted chain" — recoverable by trusting the OS
14
+ // store. CERT_HAS_EXPIRED is deliberately EXCLUDED: a genuinely expired cert must not be
15
+ // silently bypassed.
16
+ export const TLS_TRUST_CODES = new Set([
17
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
18
+ 'SELF_SIGNED_CERT_IN_CHAIN',
19
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
20
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
21
+ 'CERT_UNTRUSTED',
22
+ ]);
23
+
24
+ /**
25
+ * Walk the error cause chain — Node's fetch() wraps the real code under `.cause`, and some
26
+ * undici/TLS wrappers nest one level deeper under `.cause.cause` (mirrors the `innerCode`
27
+ * depth the sender already logs). Returns the first TLS-trust code found, else the topmost
28
+ * non-null code (useful for logging non-TLS failures too).
29
+ */
30
+ export function tlsCodeOf(err) {
31
+ if (!err) return undefined;
32
+ const codes = [err.code, err.cause?.code, err.cause?.cause?.code];
33
+ for (const c of codes) {
34
+ if (c && TLS_TRUST_CODES.has(c)) return c;
35
+ }
36
+ return codes.find((c) => c != null);
37
+ }
38
+
39
+ export function isTlsTrustError(err) {
40
+ return TLS_TRUST_CODES.has(tlsCodeOf(err));
41
+ }
42
+
43
+ /**
44
+ * Self-heal is ON by default; ONLY the exact string '1' disables it. '0'/empty/unset all keep
45
+ * it enabled (positive name on purpose — don't invert into `strictDisabled() === false`).
46
+ */
47
+ export function tlsSelfHealEnabled() {
48
+ return process.env.AI_LENS_TLS_STRICT !== '1';
49
+ }
50
+
51
+ /** `tls.getCACertificates` was added in Node 22.15.0 / 23.10.0. Older Node → no self-heal. */
52
+ export function systemCaAvailable() {
53
+ return typeof tls.getCACertificates === 'function';
54
+ }
55
+
56
+ /**
57
+ * Combine Node's bundled roots + the OS trust store + NODE_EXTRA_CA_CERTS into one deduped
58
+ * PEM array. We must include 'default' because `https.Agent({ ca })` REPLACES the bundle
59
+ * rather than appending to it. Returns undefined if the API is unavailable.
60
+ * Memoized: the trust store is effectively constant for the process lifetime, and the
61
+ * fallback path may run once per POST under sustained interception.
62
+ */
63
+ let _trustBundleCache;
64
+ export function loadTrustBundle() {
65
+ if (!systemCaAvailable()) return undefined;
66
+ if (_trustBundleCache) return _trustBundleCache;
67
+ const seen = new Set();
68
+ const bundle = [];
69
+ for (const type of ['default', 'system', 'extra']) {
70
+ let certs;
71
+ try { certs = tls.getCACertificates(type); } catch { certs = []; }
72
+ for (const pem of certs) {
73
+ if (!seen.has(pem)) { seen.add(pem); bundle.push(pem); }
74
+ }
75
+ }
76
+ _trustBundleCache = bundle;
77
+ return bundle;
78
+ }
79
+
80
+ /** Human label for a cert's issuer — single source of truth for verdicts AND detail lines. */
81
+ export function issuerName(cert) {
82
+ const iss = (cert && cert.issuer) || {};
83
+ return iss.O || iss.CN || iss.OU || '(unknown issuer)';
84
+ }
85
+
86
+ /**
87
+ * Classify the cert actually served to the client. A Let's Encrypt issuer means we reached
88
+ * the real server (so a TLS error was a transient/incomplete-chain blip). Any other issuer
89
+ * is flagged as LIKELY interception — for THIS deployment the server cert is Let's Encrypt,
90
+ * but other orgs may legitimately front AI Lens with a different CA, so the verdict hedges
91
+ * rather than asserting.
92
+ */
93
+ export function classifyTlsIssuer(cert) {
94
+ const who = issuerName(cert);
95
+ if (/let'?s encrypt/i.test(who)) return { kind: 'expected', who };
96
+ return { kind: 'interception', who };
97
+ }
98
+
99
+ /** One-line verdict string for the `status` diagnostic line. */
100
+ export function tlsVerdictSummary(cert) {
101
+ const { kind, who } = classifyTlsIssuer(cert);
102
+ if (kind === 'expected') {
103
+ return `TLS chain served by «${who}» (expected) — a TLS error here is transient/incomplete-chain, not interception`;
104
+ }
105
+ return `TLS likely intercepted by «${who}» — fix: set NODE_OPTIONS=--use-system-ca (or update the ai-lens client). If your deployment legitimately uses «${who}», ignore this.`;
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.86",
3
+ "version": "0.8.88",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {