ai-lens 0.8.85 → 0.8.87
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 +1 -1
- package/CHANGELOG.md +6 -0
- package/cli/import/claude-code.js +27 -4
- package/cli/import.js +1 -1
- package/cli/status.js +94 -3
- package/client/config.js +35 -5
- package/client/sender.js +98 -5
- package/client/tls-trust.js +106 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
ecde7fc
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
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.87 — 2026-06-11
|
|
6
|
+
- 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.
|
|
7
|
+
|
|
8
|
+
## 0.8.86 — 2026-06-11
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
5
11
|
## 0.8.85 — 2026-06-09
|
|
6
12
|
- feat: manage your own data from the CLI. `ai-lens list-sessions` lists your sessions, `ai-lens find-session <query>` finds one by id / project / source, and `ai-lens delete-sessions` removes your own sessions — by id (the short id shown by `list-sessions` works) or by `--from`/`--to` date range. Delete is a dry-run by default and only acts with `--yes`; it can only ever touch your own data, and a mistyped or ambiguous id is reported rather than silently ignored.
|
|
7
13
|
- feat: `ai-lens import claude-code --from <date> --to <date>` imports a precise date window (events outside it are left out). Re-running an import over a window you've already imported is safe — it adds zero duplicates (every event has a stable id the server de-duplicates on), so you can re-seed a lost day without fear of doubling your history.
|
|
@@ -28,7 +28,7 @@ import { spawn } from 'node:child_process';
|
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
29
|
|
|
30
30
|
import { writeToSpool, canonicalizeProjectPath, deterministicEventId } from '../../client/capture.js';
|
|
31
|
-
import { PENDING_DIR, SENDING_DIR, DATA_DIR, ensureDataDir, getGitIdentity, getGitMetadata, getServerUrl, getAuthToken } from '../../client/config.js';
|
|
31
|
+
import { PENDING_DIR, SENDING_DIR, DATA_DIR, ensureDataDir, getGitIdentity, getGitMetadata, getServerUrl, getAuthToken, getMonitoredProjects } from '../../client/config.js';
|
|
32
32
|
import { mapTranscript } from './transcript-map.js';
|
|
33
33
|
import { info, success, warn, error, heading, detail, blank } from '../logger.js';
|
|
34
34
|
|
|
@@ -125,6 +125,27 @@ function normalizeProjectArg(p) {
|
|
|
125
125
|
return canonicalizeProjectPath(real) || real;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Resolve the effective project filter, mirroring live capture by default:
|
|
130
|
+
* --projects A,B → explicit list (each entry ~-expanded + git-root canonicalized)
|
|
131
|
+
* --projects all → no filter, even when one is configured (explicit override)
|
|
132
|
+
* (no flag) → the live-capture filter (AI_LENS_PROJECTS / config
|
|
133
|
+
* `projects`), so import never ships history that live
|
|
134
|
+
* capture would have dropped via project_filter.
|
|
135
|
+
* `monitored` is getMonitoredProjects() output: normalized absolute paths or
|
|
136
|
+
* null. Matching semantics are identical to live capture's pathContains
|
|
137
|
+
* (entry === path || path inside entry), so no extra canonicalization needed.
|
|
138
|
+
* Returns { filter: string[]|null, source: 'flag'|'config'|null }.
|
|
139
|
+
*/
|
|
140
|
+
export function resolveProjectFilter(projectsFlag, monitored) {
|
|
141
|
+
if (typeof projectsFlag === 'string' && projectsFlag.trim()) {
|
|
142
|
+
if (projectsFlag.trim().toLowerCase() === 'all') return { filter: null, source: null };
|
|
143
|
+
return { filter: projectsFlag.split(',').map(normalizeProjectArg).filter(Boolean), source: 'flag' };
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(monitored) && monitored.length > 0) return { filter: monitored, source: 'config' };
|
|
146
|
+
return { filter: null, source: null };
|
|
147
|
+
}
|
|
148
|
+
|
|
128
149
|
/** Path-boundary match (so /repo does NOT match /repo2). */
|
|
129
150
|
export function projectMatches(projectPath, filters) {
|
|
130
151
|
if (!filters) return true;
|
|
@@ -299,9 +320,11 @@ export default async function importClaudeCode(flags) {
|
|
|
299
320
|
const cutoff = (rangeMode && lower == null)
|
|
300
321
|
? new Date(0).toISOString()
|
|
301
322
|
: resolveCutoff({ days, since, from });
|
|
302
|
-
const projectFilter = projects
|
|
303
|
-
|
|
304
|
-
:
|
|
323
|
+
const { filter: projectFilter, source: filterSource } = resolveProjectFilter(projects, getMonitoredProjects());
|
|
324
|
+
if (filterSource === 'config') {
|
|
325
|
+
info(`Project filter from config (AI_LENS_PROJECTS / ~/.ai-lens/config): ${projectFilter.join(', ')}`);
|
|
326
|
+
detail('Pass --projects all to import everything, or --projects A,B to override.');
|
|
327
|
+
}
|
|
305
328
|
ensureDataDir();
|
|
306
329
|
const ledger = loadLedger();
|
|
307
330
|
// Human-readable window label for the progress lines.
|
package/cli/import.js
CHANGED
|
@@ -44,7 +44,7 @@ export default async function importCmd() {
|
|
|
44
44
|
|
|
45
45
|
if (errors.length) {
|
|
46
46
|
errors.forEach((e) => error(e));
|
|
47
|
-
info('Usage: ai-lens import claude-code [--days N | --since YYYY-MM-DD | --from YYYY-MM-DD --to YYYY-MM-DD] [--projects A,B] [--dry-run] [--no-redact]');
|
|
47
|
+
info('Usage: ai-lens import claude-code [--days N | --since YYYY-MM-DD | --from YYYY-MM-DD --to YYYY-MM-DD] [--projects A,B | --projects all] [--dry-run] [--no-redact]');
|
|
48
48
|
process.exitCode = 1;
|
|
49
49
|
return;
|
|
50
50
|
}
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|