claude-mem-lite 6.22.1 → 6.23.2

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.
@@ -0,0 +1,97 @@
1
+ // lib/anthropic-base-url.mjs - one resolver for ANTHROPIC_BASE_URL.
2
+ //
3
+ // Two callers need the same answer and used to derive it differently:
4
+ // haiku-client's callModelAPI builds the request URL from it, and
5
+ // lib/llm-provider-probe.mjs picks the host + port doctor probes. When the two
6
+ // disagree, doctor certifies a hop the product never uses - the false-green
7
+ // shape this seam exists to remove. So the trim, the URL parse, and the
8
+ // validity verdict live here and both sites read the same result.
9
+ //
10
+ // ANTHROPIC_BASE_URL is the Claude Code / Anthropic SDK convention: an origin
11
+ // (+ optional /anthropic path on Azure Foundry) with NO /v1 suffix - callers
12
+ // append the endpoint path. Unset or blank keeps the public API, which is the
13
+ // behaviour before the gateway override existed.
14
+
15
+ import net from 'node:net';
16
+
17
+ const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
18
+
19
+ // Plain http:// is allowed only to loopback. Anywhere else it puts the
20
+ // x-api-key on the wire unencrypted (and prompts in the clear), so that value
21
+ // is refused rather than silently honoured.
22
+ function isLoopbackHost(host) {
23
+ return (
24
+ host === 'localhost' ||
25
+ host.endsWith('.localhost') ||
26
+ host === '::1' ||
27
+ host === '[::1]' ||
28
+ // A real dotted-quad loopback, not a DNS name that merely starts "127."
29
+ // ("127.attacker.example" resolves wherever its owner says).
30
+ (net.isIP(host) === 4 && host.startsWith('127.'))
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Resolve the configured base URL into a usable origin + path.
36
+ *
37
+ * Never throws and always returns a well-formed `url` a caller can append
38
+ * `/v1/messages` to: blank / unset keeps the public default, and a value that is
39
+ * set but unusable (unparseable, non-http(s) scheme, userinfo, a query string or
40
+ * fragment, or plain http to a non-loopback host) also returns the default so
41
+ * callers always get a parseable value. `error` names why; the direct API leg
42
+ * ignores the fallback and skips itself when a configured value is unusable, and
43
+ * doctor surfaces the same reason instead of probing a hop nothing uses.
44
+ *
45
+ * @returns {{url: string, configured: boolean, error: string|null}}
46
+ * url normalized origin+path, no trailing slash
47
+ * configured ANTHROPIC_BASE_URL was set to a non-blank value
48
+ * error why a configured value is unusable, or null when it is fine
49
+ */
50
+ export function resolveAnthropicBaseUrl() {
51
+ const raw = (process.env.ANTHROPIC_BASE_URL || '').trim();
52
+ if (!raw) return { url: DEFAULT_ANTHROPIC_BASE_URL, configured: false, error: null };
53
+
54
+ let parsed;
55
+ try {
56
+ parsed = new URL(raw);
57
+ } catch {
58
+ return {
59
+ url: DEFAULT_ANTHROPIC_BASE_URL,
60
+ configured: true,
61
+ error: 'ANTHROPIC_BASE_URL is not a valid URL',
62
+ };
63
+ }
64
+ if (parsed.username || parsed.password) {
65
+ // fetch() refuses to build a request with credentials in the URL, so every
66
+ // call would fall back to the CLI while doctor reported the host reachable.
67
+ return {
68
+ url: DEFAULT_ANTHROPIC_BASE_URL,
69
+ configured: true,
70
+ error: 'ANTHROPIC_BASE_URL must not include userinfo (user:pass@)',
71
+ };
72
+ }
73
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
74
+ return {
75
+ url: DEFAULT_ANTHROPIC_BASE_URL,
76
+ configured: true,
77
+ error: `ANTHROPIC_BASE_URL must use http or https (got ${parsed.protocol})`,
78
+ };
79
+ }
80
+ // Raw string, not URL.search/hash: a bare trailing "?" or "#" leaves both
81
+ // empty while still swallowing the appended path into the query.
82
+ if (/[?#]/.test(raw)) {
83
+ return {
84
+ url: DEFAULT_ANTHROPIC_BASE_URL,
85
+ configured: true,
86
+ error: 'ANTHROPIC_BASE_URL must not include a query string or fragment',
87
+ };
88
+ }
89
+ if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {
90
+ return {
91
+ url: DEFAULT_ANTHROPIC_BASE_URL,
92
+ configured: true,
93
+ error: `ANTHROPIC_BASE_URL uses plain http to a non-loopback host (${parsed.hostname}); the direct API leg will not send the API key there unencrypted, but the claude CLI fallback reads the same variable and still does`,
94
+ };
95
+ }
96
+ return { url: raw.replace(/\/+$/, ''), configured: true, error: null };
97
+ }
@@ -86,6 +86,11 @@ export function deleteObservations(db, ids, { snapshotTag = 'pre-delete' } = {})
86
86
  WHERE superseded_by IN (${placeholders}) AND id NOT IN (${placeholders})`,
87
87
  )
88
88
  .run(...ids, ...ids).changes;
89
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'search_results'").get()) {
90
+ db.prepare(
91
+ `UPDATE search_results SET snapshot_label = NULL WHERE source = 'obs' AND result_id IN (${placeholders})`,
92
+ ).run(...ids);
93
+ }
89
94
  // Execute deletion (FTS5 cleanup handled by the observations_ad trigger).
90
95
  const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
91
96
  return { changes: deleted.changes, recovered, restoredSuperseded };
@@ -18,6 +18,7 @@ import net from 'node:net';
18
18
  import { accessSync, statSync, constants as fsConstants } from 'node:fs';
19
19
  import { join, delimiter } from 'node:path';
20
20
  import { httpConnectProxyFor, connectProbeViaProxy, redactProxyUrl } from './proxy-fetch.mjs';
21
+ import { resolveAnthropicBaseUrl } from './anthropic-base-url.mjs';
21
22
 
22
23
  /**
23
24
  * Does `cmd` name an executable file — directly when it carries a path separator, else by a
@@ -45,6 +46,21 @@ function resolvesToExecutable(cmd, env = process.env) {
45
46
 
46
47
  const PROVIDER_HOST = { api: 'api.anthropic.com', openrouter: 'openrouter.ai' };
47
48
 
49
+ /**
50
+ * Host and port the keyed provider actually serves from. The direct-API leg
51
+ * honours ANTHROPIC_BASE_URL (haiku-client's callModelAPI), so probing
52
+ * api.anthropic.com would certify a gateway deployment against a hop the
53
+ * product never uses - the same false-green shape the proxy seam below exists
54
+ * to avoid.
55
+ * @param {'api'|'openrouter'} mode
56
+ * @returns {{host: string, port: number}}
57
+ */
58
+ function providerEndpoint(mode) {
59
+ if (mode !== 'api') return { host: PROVIDER_HOST[mode], port: 443 };
60
+ const u = new URL(resolveAnthropicBaseUrl().url);
61
+ return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
62
+ }
63
+
48
64
  /**
49
65
  * Open and immediately close a TCP connection. No TLS, no request, no key.
50
66
  * @param {string} host
@@ -126,8 +142,30 @@ export async function llmProviderStatus({
126
142
  };
127
143
  }
128
144
 
129
- const host = PROVIDER_HOST[mode];
130
- const proxy = httpConnectProxyFor(`https://${host}/`);
145
+ // A set-but-unusable base URL is the always-unset variant of the false green:
146
+ // callModelAPI skips the direct leg entirely, so every keyed call falls back
147
+ // to the claude CLI and probing any host would just certify a hop nothing
148
+ // uses. Warn and stop instead of probing a hop the configuration does not
149
+ // describe.
150
+ const base = resolveAnthropicBaseUrl();
151
+ if (mode === 'api' && base.error) {
152
+ return {
153
+ mode,
154
+ level: 'warn',
155
+ message:
156
+ `LLM provider: ${base.error} - ` +
157
+ 'the direct API leg is skipped and every background LLM call falls back to the ' +
158
+ 'claude CLI, which starts a process per call; fix or unset ANTHROPIC_BASE_URL.',
159
+ };
160
+ }
161
+
162
+ const { host, port } = providerEndpoint(mode);
163
+ // Select the proxy off the SAME URL the product calls, so doctor exercises the
164
+ // hop the direct leg actually takes. A plain http gateway (loopback) bypasses
165
+ // the TLS-only tunnel in the product, so it must bypass it here too or doctor
166
+ // reports "via proxy" for a request that never touches one.
167
+ const proxyTarget = mode === 'api' ? `${base.url}/` : `https://${host}/`;
168
+ const proxy = httpConnectProxyFor(proxyTarget);
131
169
  // Report the hop actually exercised. When a proxy is configured the request
132
170
  // path is node → proxy → host, so probing the host directly would answer a
133
171
  // question the product never asks — and on a machine where only the proxy is
@@ -142,7 +180,11 @@ export async function llmProviderStatus({
142
180
  // occupied": a plain TCP connect passes against a SOCKS-only listener or a
143
181
  // proxy that forbids CONNECT, and doctor would then certify a dead
144
182
  // provider. (pre-tag review)
145
- result = proxy ? await _proxyProbe(proxy, host, { timeout: 4000 }) : await _probe(host, { port: 443 });
183
+ // URL.hostname keeps an IPv6 literal's brackets, which CONNECT needs ([::1]:443) and
184
+ // net.connect cannot resolve (ENOTFOUND), so only the direct probe drops them.
185
+ result = proxy
186
+ ? await _proxyProbe(proxy, host, { timeout: 4000, port })
187
+ : await _probe(host.replace(/^\[|\]$/g, ''), { port });
146
188
  } catch (e) {
147
189
  result = { reachable: false, error: e?.message || String(e) };
148
190
  }
@@ -21,6 +21,7 @@
21
21
 
22
22
  import http from 'node:http';
23
23
  import https from 'node:https';
24
+ import net from 'node:net';
24
25
  import tls from 'node:tls';
25
26
 
26
27
  /**
@@ -35,18 +36,61 @@ export function httpConnectProxyFor(targetUrl) {
35
36
  process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
36
37
  if (!proxy || !/^https?:\/\//.test(proxy)) return null; // socks5 ALL_PROXY not supported here
37
38
  try {
38
- const host = new URL(targetUrl).hostname;
39
- const noProxy = (process.env.NO_PROXY || process.env.no_proxy || '')
40
- .split(',')
41
- .map((s) => s.trim())
42
- .filter(Boolean);
43
- if (noProxy.some((n) => n === host || (n.startsWith('.') && host.endsWith(n.slice(1))))) return null;
39
+ const target = new URL(targetUrl);
40
+ // The tunnel below is TLS-only (https.request + tls.connect). A plain
41
+ // http:// target must NOT take it: the CONNECT succeeds, then the request
42
+ // throws ERR_INVALID_PROTOCOL from inside an event handler, past the
43
+ // caller's try/catch, and takes the process (MCP server included) down.
44
+ // Native fetch handles http directly, so the caller keeps that path.
45
+ if (target.protocol !== 'https:') return null;
46
+ const noProxy = process.env.NO_PROXY || process.env.no_proxy || '';
47
+ if (noProxyMatches(target.hostname, Number(target.port) || 443, noProxy)) return null;
44
48
  return proxy;
45
49
  } catch {
46
50
  return null;
47
51
  }
48
52
  }
49
53
 
54
+ /**
55
+ * Does a NO_PROXY list exempt this host? NO_PROXY has no standard (survey:
56
+ * about.gitlab.com/blog/we-need-to-talk-no-proxy). Every common client suffix-matches a
57
+ * name; beyond that they disagree, so for names and ports this accepts the UNION of the
58
+ * forms curl, Python and Go accept, and it can exempt a host one of them would still
59
+ * proxy. Case-insensitive; a '*' entry exempts every host (Go); a name entry covers itself
60
+ * and every subdomain on a '.' boundary, with or without a leading '.' (curl, Python) or
61
+ * '*.' (Go); an optional :port narrows the entry to that port (Python, Go); IPv6 with or
62
+ * without brackets. Narrower than the union: an IP entry matches only the canonical
63
+ * spelling URL.hostname uses, never as a suffix (Python suffix-matches IPs), no CIDR
64
+ * (curl, Go), no IDN-to-xn-- conversion (Go), no DNS lookups. NO_PROXY is read before
65
+ * no_proxy; curl, Python and Go (golang.org/x/net/http/httpproxy) read no_proxy first.
66
+ * @param {string} hostname URL.hostname (lowercase; IPv6 bracketed)
67
+ * @param {number} port effective target port
68
+ * @param {string} list raw NO_PROXY value
69
+ * @returns {boolean}
70
+ */
71
+ function noProxyMatches(hostname, port, list) {
72
+ const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
73
+ for (const raw of list.split(',')) {
74
+ let entry = raw.trim().toLowerCase();
75
+ if (!entry) continue;
76
+ if (entry === '*') return true;
77
+ let entryPort = null;
78
+ const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(entry);
79
+ if (bracketed) {
80
+ entry = bracketed[1];
81
+ entryPort = bracketed[2] ?? null;
82
+ } else if (entry.split(':').length === 2) {
83
+ [entry, entryPort] = entry.split(':');
84
+ }
85
+ if (entryPort !== null && Number(entryPort) !== port) continue;
86
+ entry = entry.replace(/^\*?\./, '').replace(/\.$/, '');
87
+ if (!entry) continue;
88
+ if (host === entry) return true;
89
+ if (!net.isIP(host) && host.endsWith(`.${entry}`)) return true;
90
+ }
91
+ return false;
92
+ }
93
+
50
94
  /**
51
95
  * Proxy-Authorization header for a proxy URL that carries credentials, or {} when it does
52
96
  * not. R10 P2-14.
@@ -173,6 +217,13 @@ export function onceViaConnectProxy(
173
217
  } catch (e) {
174
218
  return reject(e);
175
219
  }
220
+ // TLS-only: reject here, synchronously, rather than let https.request throw
221
+ // ERR_INVALID_PROTOCOL inside the CONNECT callback where no caller can see
222
+ // it. httpConnectProxyFor already declines http targets; this is the second
223
+ // lock on the same door for any direct caller. (pre-tag review on #33)
224
+ if (t.protocol !== 'https:') {
225
+ return reject(new Error(`CONNECT tunnel supports https targets only (got ${t.protocol})`));
226
+ }
176
227
  const port = Number(t.port) || 443;
177
228
  let settled = false;
178
229
  const finish = (fn, arg) => {
@@ -220,11 +271,24 @@ export function onceViaConnectProxy(
220
271
  socket.destroy();
221
272
  return finish(reject, new Error(`proxy CONNECT ${res.statusCode}`));
222
273
  }
274
+ // SNI carries DNS names only: Node throws ERR_INVALID_ARG_VALUE for an IP
275
+ // servername, and a base URL (#33) can be an IP literal. `host` still drives
276
+ // the certificate identity check, brackets stripped for IPv6.
277
+ const tlsHost = t.hostname.replace(/^\[|\]$/g, '');
278
+ const sni = net.isIP(tlsHost) ? {} : { servername: tlsHost };
223
279
  const req = https.request(
224
280
  url,
225
- { method, headers, createConnection: () => tls.connect({ socket, servername: t.hostname }) },
281
+ { method, headers, createConnection: () => tls.connect({ socket, host: tlsHost, ...sni }) },
226
282
  (resp) => {
227
283
  const chunks = [];
284
+ // A connection dropped mid-body destroys the response without 'end' (and
285
+ // emits 'error' only to a listener), but 'close' always fires. Without this
286
+ // the call stayed pending until the unref'd overall timer, so an otherwise
287
+ // idle worker exited with it unsettled and its caller's fallback never ran.
288
+ // Native fetch rejects here too.
289
+ resp.on('close', () => {
290
+ if (!resp.complete) finish(reject, new Error('proxy response closed before the body ended'));
291
+ });
228
292
  resp.on('data', (c) => chunks.push(Buffer.from(c)));
229
293
  resp.on('end', () => {
230
294
  const buf = Buffer.concat(chunks);