ashlr-plugin 1.36.4 → 1.37.1

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.
Files changed (81) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +49 -8
  5. package/commands/ashlr-doctor.md +29 -0
  6. package/commands/ashlr-status.md +1 -1
  7. package/docs/PHANTOM-USAGE.md +58 -0
  8. package/docs/architecture.md +5 -5
  9. package/docs/ashlr-handshake.md +36 -0
  10. package/docs/assets/og.svg +1 -1
  11. package/docs/benchmarks-v2.json +226 -177
  12. package/docs/benchmarks.md +20 -10
  13. package/docs/competitive-wozcode.md +2 -2
  14. package/docs/embeddings-gateway.md +39 -0
  15. package/docs/index.html +3 -3
  16. package/docs/legal.md +1 -1
  17. package/docs/llm-providers.md +15 -1
  18. package/docs/multi-host-mcp.md +25 -3
  19. package/docs/phantom-preflight.md +23 -0
  20. package/docs/team-genome.md +2 -1
  21. package/hooks/_hook-timing-ledger.ts +2 -9
  22. package/hooks/hooks.json +3 -106
  23. package/hooks/posttooluse-stale-result.ts +3 -0
  24. package/hooks/pretooluse-common.ts +1 -1
  25. package/hooks/pretooluse-notebookedit.ts +1 -1
  26. package/hooks/pretooluse-write.ts +1 -1
  27. package/hooks/session-start.ts +48 -26
  28. package/package.json +9 -4
  29. package/ports/cursor/mcp.json +1 -1
  30. package/scripts/_read-thresholds.ts +119 -0
  31. package/scripts/ashlr-mcp.mjs +15 -0
  32. package/scripts/ashlr-mcp.ts +3 -0
  33. package/scripts/ashlr-plugin.mjs +59 -0
  34. package/scripts/bench-read-real.ts +90 -0
  35. package/scripts/bootstrap.mjs +14 -4
  36. package/scripts/cli.ts +35 -10
  37. package/scripts/context-status.ts +7 -2
  38. package/scripts/deploy-smoke.sh +8 -4
  39. package/scripts/doctor.ts +171 -42
  40. package/scripts/genome-auto-consolidate.ts +4 -2
  41. package/scripts/genome-init.ts +3 -3
  42. package/scripts/genome-key.ts +64 -7
  43. package/scripts/handshake.mjs +51 -0
  44. package/scripts/hook-bootstrap.mjs +170 -17
  45. package/scripts/mcp-entrypoint.ts +3 -0
  46. package/scripts/ollama-setup.ts +5 -4
  47. package/scripts/run-benchmark.ts +28 -18
  48. package/scripts/upgrade-flow.ts +9 -14
  49. package/servers/_base-urls.ts +48 -0
  50. package/servers/_companions.ts +258 -0
  51. package/servers/_cwd-clamp.ts +38 -1
  52. package/servers/_edit-match.ts +12 -2
  53. package/servers/_embedding-model.ts +77 -0
  54. package/servers/_gateway-target.ts +179 -0
  55. package/servers/_llm-providers/anthropic.ts +10 -4
  56. package/servers/_llm-providers/cloud.ts +9 -7
  57. package/servers/_llm-providers/index.ts +8 -6
  58. package/servers/_llm-providers/local.ts +9 -5
  59. package/servers/_mcp-host.ts +6 -0
  60. package/servers/_miss-tracker.ts +121 -0
  61. package/servers/_phantom-exec.ts +135 -0
  62. package/servers/_phantom-preflight.ts +130 -0
  63. package/servers/_pid-alive.ts +40 -0
  64. package/servers/_pro-token-keychain.ts +149 -0
  65. package/servers/_redact-keys.ts +75 -0
  66. package/servers/_rg-text.ts +111 -0
  67. package/servers/_router.ts +20 -2
  68. package/servers/_run-with-timeout.ts +56 -12
  69. package/servers/_test-watch.ts +1 -10
  70. package/servers/_token-measure.ts +3 -1
  71. package/servers/_tool-base.ts +6 -0
  72. package/servers/bash-server.ts +1 -10
  73. package/servers/edit-server.ts +24 -1
  74. package/servers/efficiency-server.ts +12 -2
  75. package/servers/genome-server-handlers.ts +2 -2
  76. package/servers/genome-server.ts +17 -17
  77. package/servers/grep-server.ts +6 -2
  78. package/servers/orient-server-handlers.ts +3 -2
  79. package/servers/orient-server.ts +16 -9
  80. package/servers/read-server.ts +1 -1
  81. package/servers/write-server-handlers.ts +10 -2
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Ashlr companion detection for `ashlr doctor`: asks each installed companion
3
+ * for `--ashlr-handshake --json` (docs/ashlr-handshake.md) and reports name,
4
+ * version, capabilities and readiness. Same rules as `stack doctor --ecosystem`:
5
+ * a short timeout per probe, an environment with no secrets, and a missing or
6
+ * too-old companion is reported, never a failure.
7
+ */
8
+
9
+ import { spawn } from "child_process";
10
+
11
+ export interface Companion {
12
+ name: string;
13
+ command: string;
14
+ repo: string;
15
+ }
16
+
17
+ export const COMPANIONS: Companion[] = [
18
+ { name: "phantom", command: "phm", repo: "ashlrai/phantom" },
19
+ {
20
+ name: "phantom-secrets",
21
+ command: "phantom",
22
+ repo: "ashlrai/phantom-secrets",
23
+ },
24
+ { name: "locus", command: "locus", repo: "ashlrai/locus" },
25
+ { name: "lexicon", command: "lexicon", repo: "ashlrai/lexicon" },
26
+ { name: "ashlr-md", command: "mdopener-mcp", repo: "ashlrai/ashlr-md" },
27
+ { name: "ashlr-stack", command: "stack", repo: "ashlrai/ashlr-stack" },
28
+ ];
29
+
30
+ export const COMPANION_TIMEOUT_MS = 2000;
31
+
32
+ export type CompanionRun =
33
+ | { kind: "ran"; code: number | null; stdout: string }
34
+ | { kind: "missing" }
35
+ | { kind: "timeout" };
36
+
37
+ export type CompanionRunner = (
38
+ command: string,
39
+ timeoutMs: number,
40
+ ) => Promise<CompanionRun>;
41
+
42
+ export interface CompanionResult {
43
+ name: string;
44
+ command: string;
45
+ /**
46
+ * ready: answered v1 with health ok; degraded: answered, health not ok;
47
+ * outdated: ran but printed no JSON (too old for the handshake);
48
+ * unknown: timed out, or printed JSON that is not a v1 handshake;
49
+ * missing: not installed.
50
+ */
51
+ state: "ready" | "degraded" | "outdated" | "unknown" | "missing";
52
+ version: string | null;
53
+ capabilities: string[];
54
+ /** The handshake's health.status as reported, or null when there was no v1 answer. */
55
+ health: string | null;
56
+ fix: string | null;
57
+ /** Capability-gated commands to run next (same rules as `stack doctor --ecosystem`). */
58
+ next_steps: string[];
59
+ }
60
+
61
+ /** Handshake capability a companion advertises when `<command> doctor --fix` exists. */
62
+ export const DOCTOR_FIX_CAPABILITY = "doctor-fix";
63
+ /** Kept for callers of #218. */
64
+ export const LOCUS_DOCTOR_FIX_CAPABILITY = DOCTOR_FIX_CAPABILITY;
65
+
66
+ /**
67
+ * Companions whose `doctor --fix` is a safe dry run by default (writes
68
+ * nothing; `--apply` makes the repairs). Keyed by companion name, valued by
69
+ * the command the user runs.
70
+ */
71
+ export const DOCTOR_FIX_COMMANDS: Record<string, string> = {
72
+ locus: "locus",
73
+ lexicon: "lexicon",
74
+ "ashlr-md": "mdopener-mcp",
75
+ };
76
+
77
+ export const doctorFixStep = (command: string): string =>
78
+ `${command} doctor --fix # preview safe repairs (writes nothing); add --apply to make them`;
79
+
80
+ export const LOCUS_DOCTOR_FIX_STEP = doctorFixStep("locus");
81
+ export const LEXICON_DOCTOR_FIX_STEP = doctorFixStep("lexicon");
82
+ export const MDOPENER_DOCTOR_FIX_STEP = doctorFixStep("mdopener-mcp");
83
+
84
+ /**
85
+ * Next steps for one companion. Keyed on what the companion itself
86
+ * advertises, never on its version: `<command> doctor --fix` only when the
87
+ * companion is one of DOCTOR_FIX_COMMANDS, answered, its health is not ok,
88
+ * and it lists `doctor-fix`.
89
+ */
90
+ export function companionNextSteps(
91
+ r: Pick<CompanionResult, "name" | "state" | "health" | "capabilities">,
92
+ ): string[] {
93
+ const command = new Map(Object.entries(DOCTOR_FIX_COMMANDS)).get(r.name);
94
+ if (
95
+ command !== undefined &&
96
+ r.state === "degraded" &&
97
+ r.health !== null &&
98
+ r.health !== "ok" &&
99
+ r.capabilities.includes(DOCTOR_FIX_CAPABILITY)
100
+ )
101
+ return [doctorFixStep(command)];
102
+ return [];
103
+ }
104
+
105
+ /** PATH, HOME and locale only: no token in this process reaches a companion. */
106
+ export function companionEnv(
107
+ env: Record<string, string | undefined> = process.env,
108
+ ): Record<string, string> {
109
+ const out: Record<string, string> = {};
110
+ for (const k of [
111
+ "PATH",
112
+ "HOME",
113
+ "USER",
114
+ "LANG",
115
+ "LC_ALL",
116
+ "TMPDIR",
117
+ "SYSTEMROOT",
118
+ "PATHEXT",
119
+ ]) {
120
+ const v = env[k];
121
+ if (v !== undefined) out[k] = v;
122
+ }
123
+ out.NO_COLOR = "1";
124
+ return out;
125
+ }
126
+
127
+ export const defaultCompanionRunner: CompanionRunner = (command, timeoutMs) =>
128
+ new Promise((resolve) => {
129
+ let stdout = "";
130
+ let done = false;
131
+ const finish = (r: CompanionRun) => {
132
+ if (!done) {
133
+ done = true;
134
+ resolve(r);
135
+ }
136
+ };
137
+ let child: ReturnType<typeof spawn>;
138
+ try {
139
+ child = spawn(command, ["--ashlr-handshake", "--json"], {
140
+ env: companionEnv(),
141
+ stdio: ["ignore", "pipe", "ignore"],
142
+ });
143
+ } catch {
144
+ finish({ kind: "missing" });
145
+ return;
146
+ }
147
+ const timer = setTimeout(() => {
148
+ child.kill("SIGKILL");
149
+ finish({ kind: "timeout" });
150
+ }, timeoutMs);
151
+ child.stdout?.on("data", (d) => {
152
+ if (stdout.length < 64_000) stdout += String(d);
153
+ });
154
+ child.on("error", (err: NodeJS.ErrnoException) => {
155
+ clearTimeout(timer);
156
+ finish(
157
+ err.code === "ENOENT"
158
+ ? { kind: "missing" }
159
+ : { kind: "ran", code: 1, stdout: "" },
160
+ );
161
+ });
162
+ child.on("close", (code) => {
163
+ clearTimeout(timer);
164
+ finish({ kind: "ran", code, stdout });
165
+ });
166
+ });
167
+
168
+ export function parseCompanion(
169
+ c: Companion,
170
+ run: CompanionRun,
171
+ ): CompanionResult {
172
+ const base = {
173
+ name: c.name,
174
+ command: c.command,
175
+ version: null,
176
+ capabilities: [] as string[],
177
+ health: null,
178
+ next_steps: [] as string[],
179
+ };
180
+ const docs = `https://github.com/${c.repo}#readme`;
181
+ if (run.kind === "missing")
182
+ return {
183
+ ...base,
184
+ state: "missing",
185
+ fix: `optional: install ${c.name} (\`${c.command}\`), see ${docs}`,
186
+ };
187
+ if (run.kind === "timeout")
188
+ return {
189
+ ...base,
190
+ state: "unknown",
191
+ fix: `\`${c.command} --ashlr-handshake --json\` did not answer in time`,
192
+ };
193
+ const text = run.stdout.trim();
194
+ let v: unknown;
195
+ let parsed = true;
196
+ try {
197
+ v = JSON.parse(text);
198
+ } catch {
199
+ parsed = false;
200
+ }
201
+ // Not JSON at all: an older build that does not know the flag.
202
+ if (!parsed && !text.startsWith("{"))
203
+ return {
204
+ ...base,
205
+ state: "outdated",
206
+ fix: `upgrade ${c.name}: \`${c.command}\` does not answer --ashlr-handshake (see ${docs})`,
207
+ };
208
+ if (!parsed || v === null || typeof v !== "object")
209
+ return {
210
+ ...base,
211
+ state: "unknown",
212
+ fix: `\`${c.command} --ashlr-handshake --json\` printed output doctor cannot read`,
213
+ };
214
+ const h = v as Record<string, unknown> & { health?: { status?: unknown } };
215
+ if (h.kind !== "ashlr.handshake" || h.schema_version !== 1)
216
+ return {
217
+ ...base,
218
+ state: "unknown",
219
+ fix: `\`${c.command} --ashlr-handshake --json\` printed output doctor cannot read`,
220
+ };
221
+ const health =
222
+ typeof h.health?.status === "string" ? h.health.status : "unknown";
223
+ const result: CompanionResult = {
224
+ ...base,
225
+ state: health === "ok" ? "ready" : "degraded",
226
+ version: typeof h.version === "string" ? h.version : null,
227
+ capabilities: Array.isArray(h.capabilities)
228
+ ? h.capabilities.filter((x): x is string => typeof x === "string")
229
+ : [],
230
+ health,
231
+ fix:
232
+ health === "ok"
233
+ ? null
234
+ : `run \`${c.command} --ashlr-handshake --json\` to see which check is not ok`,
235
+ };
236
+ result.next_steps = companionNextSteps(result);
237
+ if (result.next_steps.length)
238
+ result.fix = `run \`${DOCTOR_FIX_COMMANDS[c.name]} doctor --fix\` to preview safe repairs (writes nothing); add --apply to make them`;
239
+ return result;
240
+ }
241
+
242
+ /** Probes every companion in parallel. Never throws. */
243
+ export async function detectCompanions(
244
+ runner: CompanionRunner = defaultCompanionRunner,
245
+ companions: Companion[] = COMPANIONS,
246
+ timeoutMs = COMPANION_TIMEOUT_MS,
247
+ ): Promise<CompanionResult[]> {
248
+ return Promise.all(
249
+ companions.map(async (c) =>
250
+ parseCompanion(
251
+ c,
252
+ await runner(c.command, timeoutMs).catch(
253
+ (): CompanionRun => ({ kind: "missing" }),
254
+ ),
255
+ ),
256
+ ),
257
+ );
258
+ }
@@ -277,6 +277,31 @@ function allowedRoots(): string[] {
277
277
  return roots;
278
278
  }
279
279
 
280
+ /**
281
+ * Move the MCP router into the folder its host launched it in.
282
+ *
283
+ * The launchers (scripts/mcp-entrypoint.ts, bootstrap.mjs, ashlr-mcp.ts) chdir
284
+ * to PLUGIN_ROOT so dependency resolution never trusts the caller's cwd, and
285
+ * record the original folder in ASHLR_LAUNCH_CWD first. Hosts start MCP
286
+ * servers in the user's project, so adopting it makes every tool default
287
+ * (`process.cwd()`, relative paths, the clamp's primary root) mean the project
288
+ * instead of the plugin install dir. Ignored when the recorded folder is the
289
+ * plugin install dir itself or no longer exists. Returns the adopted folder.
290
+ */
291
+ export function adoptLaunchCwd(): string | null {
292
+ const launchCwd = process.env["ASHLR_LAUNCH_CWD"];
293
+ if (!launchCwd) return null;
294
+ try {
295
+ const abs = canonical(resolve(launchCwd));
296
+ if (cwdLooksLikePluginRoot(abs) || !statSync(abs).isDirectory()) return null;
297
+ if (abs === canonical(process.cwd())) return abs;
298
+ process.chdir(abs);
299
+ return abs;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
280
305
  /**
281
306
  * Pick the user's primary project root from the allow-list.
282
307
  *
@@ -317,7 +342,9 @@ export function clampToCwd(
317
342
  toolName: string,
318
343
  ): ClampResult {
319
344
  const roots = allowedRoots();
320
- const rootAbs = canonical(resolve(userPath ?? "."));
345
+ // Relative paths (and an omitted path) mean the user's project, not the
346
+ // plugin install dir the router process runs from.
347
+ const rootAbs = canonical(resolve(primaryProjectRoot(), userPath ?? "."));
321
348
 
322
349
  for (const root of roots) {
323
350
  const rel = relative(root, rootAbs);
@@ -340,3 +367,13 @@ export function clampToCwd(
340
367
  message: `${toolName}: refused path outside working directory: ${absInput}${jailDiag}\n(cwd is ${primary}${extra})`,
341
368
  };
342
369
  }
370
+
371
+ const REFUSAL = /^ashlr__\w+: refused path outside working directory: /;
372
+
373
+ /**
374
+ * True when `text` is a clampToCwd refusal. Many handlers return the refusal
375
+ * as plain text; the dispatcher uses this to mark those results `isError`.
376
+ */
377
+ export function isPathRefusal(text: unknown): boolean {
378
+ return typeof text === "string" && REFUSAL.test(text);
379
+ }
@@ -134,7 +134,10 @@ export function findFuzzyMatch(
134
134
  // -------------------------------------------------------------------------
135
135
  // Tier 2: block Levenshtein over line windows
136
136
  // -------------------------------------------------------------------------
137
- const searchLines = search.split("\n");
137
+ // A trailing newline in `search` ends its last line; it is not an extra
138
+ // empty line to compare against the next line of content.
139
+ const searchEndsWithNewline = search.endsWith("\n");
140
+ const searchLines = (searchEndsWithNewline ? search.slice(0, -1) : search).split("\n");
138
141
  const contentLines = content.split("\n");
139
142
  const windowSize = searchLines.length;
140
143
 
@@ -170,10 +173,17 @@ export function findFuzzyMatch(
170
173
  // Map best window back to byte offsets.
171
174
  const start = lineToOffset(content, best.lineIdx);
172
175
  const endLineIdx = best.lineIdx + windowSize;
173
- const end =
176
+ // The window covers whole lines, but the line terminator after its last line
177
+ // belongs to the window only when `search` itself ends with a newline.
178
+ // Otherwise the replacement would swallow it and join the next line.
179
+ let end =
174
180
  endLineIdx >= contentLines.length
175
181
  ? content.length
176
182
  : lineToOffset(content, endLineIdx);
183
+ if (!searchEndsWithNewline && endLineIdx < contentLines.length) {
184
+ end -= 1; // the "\n"
185
+ if (end > start && content[end - 1] === "\r") end -= 1;
186
+ }
177
187
 
178
188
  return { start, end, score: Math.round(best.score * 100) / 100 };
179
189
  }
@@ -18,15 +18,30 @@
18
18
  * Set ASHLR_EMBED_URL=http://localhost:11434/api/embeddings to call an
19
19
  * Ollama/LM Studio endpoint. Falls back to BM25 on any failure.
20
20
  *
21
+ * OpenAI-compatible embedding (opt-in, off by default):
22
+ * Set ASHLR_EMBED_FORMAT=openai and ASHLR_EMBED_URL to an OpenAI-compatible
23
+ * base such as a LiteLLM proxy (http://127.0.0.1:4000/v1); the plugin POSTs
24
+ * { model, input } to <base>/embeddings. Optional ASHLR_EMBED_KEY is sent
25
+ * as a Bearer token; a Phantom `phm_` placeholder is sent unchanged for the
26
+ * proxy to swap, a `phm:` reference is never resolved (no call is made).
27
+ * The call follows the gateway rules (https, or http only to 127.0.0.1 /
28
+ * [::1]; no credentials in the URL; redirects refused; strict mode adds the
29
+ * allowlist). The key and the embedded text are never logged.
30
+ * See docs/embeddings-gateway.md.
31
+ *
21
32
  * Env vars:
22
33
  * ASHLR_EMBED_URL — HTTP POST endpoint that returns { embedding: number[] }
34
+ * (with ASHLR_EMBED_FORMAT=openai: the OpenAI-compatible base URL)
23
35
  * ASHLR_EMBED_MODEL — model name to pass in the request body (default: nomic-embed-text)
36
+ * ASHLR_EMBED_FORMAT : "ollama" (default) or "openai"
37
+ * ASHLR_EMBED_KEY : optional key, used only with ASHLR_EMBED_FORMAT=openai
24
38
  * ASHLR_CONTEXT_DB_DISABLE — if "1", embed() still works (used outside the cache path)
25
39
  */
26
40
 
27
41
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
28
42
  import { homedir } from "os";
29
43
  import { join } from "path";
44
+ import { safeKeyedFetch } from "./_gateway-target.ts";
30
45
 
31
46
  // ---------------------------------------------------------------------------
32
47
  // Constants
@@ -259,9 +274,71 @@ export function bm25Embed(text: string): Float32Array {
259
274
  * Call a remote embedding endpoint.
260
275
  * Returns null on any failure so the caller can fall back to BM25.
261
276
  */
277
+ type Env = Record<string, string | undefined>;
278
+
279
+ export type EmbedFormat = "ollama" | "openai";
280
+
281
+ /** "openai" only when ASHLR_EMBED_FORMAT is exactly that (case-insensitive); anything else keeps the Ollama shape. */
282
+ export function embedFormat(env: Env = process.env): EmbedFormat {
283
+ return (env.ASHLR_EMBED_FORMAT ?? "").trim().toLowerCase() === "openai" ? "openai" : "ollama";
284
+ }
285
+
286
+ /** OpenAI-compatible embeddings URL: `<base>/embeddings`, unless the URL already ends in /embeddings. */
287
+ export function openaiEmbeddingsUrl(base: string): string {
288
+ const trimmed = base.trim().replace(/\/+$/, "");
289
+ return /\/embeddings$/i.test(trimmed) ? trimmed : `${trimmed}/embeddings`;
290
+ }
291
+
292
+ /**
293
+ * Opt-in OpenAI-compatible embedding (LiteLLM and similar). Returns null on
294
+ * any failure or refusal so the caller falls back to BM25. Writes nothing to
295
+ * stdout or stderr: neither the key nor the text is ever logged.
296
+ */
297
+ export async function openaiRemoteEmbed(
298
+ text: string,
299
+ env: Env = process.env,
300
+ fetchImpl: typeof fetch = fetch,
301
+ ): Promise<Float32Array | null> {
302
+ const base = (env.ASHLR_EMBED_URL ?? "").trim();
303
+ if (!base) return null;
304
+ const key = (env.ASHLR_EMBED_KEY ?? "").trim();
305
+ // A `phm:` vault reference is never resolved here; without a real key or a
306
+ // `phm_` session placeholder there is nothing safe to send.
307
+ if (key.startsWith("phm:")) return null;
308
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
309
+ if (key) headers["Authorization"] = `Bearer ${key}`;
310
+ const model = env.ASHLR_EMBED_MODEL ?? "nomic-embed-text";
311
+ try {
312
+ const res = await safeKeyedFetch(
313
+ openaiEmbeddingsUrl(base),
314
+ {
315
+ method: "POST",
316
+ headers,
317
+ body: JSON.stringify({ model, input: text }),
318
+ signal: AbortSignal.timeout(5_000),
319
+ },
320
+ env,
321
+ fetchImpl,
322
+ );
323
+ if (!res.ok) {
324
+ try { await res.body?.cancel(); } catch { /* nothing to drain */ }
325
+ return null;
326
+ }
327
+ const json = (await res.json()) as { data?: Array<{ embedding?: unknown }> };
328
+ const emb = json.data?.[0]?.embedding;
329
+ if (!Array.isArray(emb) || emb.length === 0 || !emb.every((x) => typeof x === "number" && Number.isFinite(x))) {
330
+ return null;
331
+ }
332
+ return normalizeInPlace(new Float32Array(emb as number[]));
333
+ } catch {
334
+ return null;
335
+ }
336
+ }
337
+
262
338
  async function remoteEmbed(text: string): Promise<Float32Array | null> {
263
339
  const url = process.env.ASHLR_EMBED_URL;
264
340
  if (!url) return null;
341
+ if (embedFormat() === "openai") return openaiRemoteEmbed(text);
265
342
 
266
343
  const model = process.env.ASHLR_EMBED_MODEL ?? "nomic-embed-text";
267
344
 
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Opt-in strict targets for model calls that carry a key.
3
+ *
4
+ * Off by default: with ASHLR_GATEWAY_STRICT unset, keyedFetch() is plain
5
+ * fetch() and nothing changes. With ASHLR_GATEWAY_STRICT=1, a request that
6
+ * carries a key (Authorization or x-api-key) is sent only when its target is:
7
+ *
8
+ * - a loopback IP literal, 127.0.0.1 or [::1] (http or https, any port), or
9
+ * - https to an exact host:port listed in ASHLR_GATEWAY_HOSTS
10
+ * (comma-separated, for example "litellm.internal:4000,gw.example.com:443"), or
11
+ * - https to one of the plugin's built-in provider hosts on 443
12
+ * (api.anthropic.com, api.openai.com, api.ashlr.ai).
13
+ *
14
+ * `localhost` by name is not loopback here, credentials in the URL are
15
+ * refused, and redirects are never followed (a 3xx is a refusal), because
16
+ * fetch forwards a custom header such as x-api-key to the redirect target.
17
+ * Refusal messages name scheme, host and port only, never the key, path or
18
+ * query.
19
+ */
20
+
21
+ type Env = Record<string, string | undefined>;
22
+
23
+ export const GATEWAY_STRICT_ENV = "ASHLR_GATEWAY_STRICT";
24
+ export const GATEWAY_HOSTS_ENV = "ASHLR_GATEWAY_HOSTS";
25
+
26
+ export const BUILTIN_PROVIDER_HOSTS: readonly string[] = [
27
+ "api.anthropic.com:443",
28
+ "api.openai.com:443",
29
+ "api.ashlr.ai:443",
30
+ ];
31
+
32
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]"]);
33
+
34
+ export class GatewayRefusal extends Error {
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = "GatewayRefusal";
38
+ }
39
+ }
40
+
41
+ /** True when ASHLR_GATEWAY_STRICT is 1, true, yes or on (any case). */
42
+ export function gatewayStrict(env: Env = process.env): boolean {
43
+ const v = (env[GATEWAY_STRICT_ENV] ?? "").trim().toLowerCase();
44
+ return v === "1" || v === "true" || v === "yes" || v === "on";
45
+ }
46
+
47
+ /**
48
+ * Parse ASHLR_GATEWAY_HOSTS. Each entry must be an exact host:port. Returns
49
+ * the invalid entries separately so a typo refuses every listed target
50
+ * instead of silently allowing less (or more) than intended.
51
+ */
52
+ export function parseGatewayHosts(env: Env = process.env): { hosts: Set<string>; invalid: string[] } {
53
+ const hosts = new Set<string>();
54
+ const invalid: string[] = [];
55
+ for (const raw of (env[GATEWAY_HOSTS_ENV] ?? "").split(",")) {
56
+ const entry = raw.trim().toLowerCase();
57
+ if (!entry) continue;
58
+ const m = /^(\[[0-9a-f:.]+\]|[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*):(\d{1,5})$/.exec(entry);
59
+ const port = m ? Number(m[5]) : 0;
60
+ if (!m || port < 1 || port > 65535) {
61
+ invalid.push(raw.trim().slice(0, 80));
62
+ continue;
63
+ }
64
+ hosts.add(`${m[1]}:${port}`);
65
+ }
66
+ return { hosts, invalid };
67
+ }
68
+
69
+ /** scheme://host:port of a URL, nothing else; "(invalid URL)" when it does not parse. */
70
+ export function safeTargetLabel(url: string): string {
71
+ try {
72
+ const u = new URL(url);
73
+ const port = u.port || (u.protocol === "https:" ? "443" : u.protocol === "http:" ? "80" : "");
74
+ return `${u.protocol}//${u.hostname}${port ? `:${port}` : ""}`;
75
+ } catch {
76
+ return "(invalid URL)";
77
+ }
78
+ }
79
+
80
+ export type TargetCheck = { ok: true } | { ok: false; reason: string };
81
+
82
+ /** Decide whether a keyed request may go to `url` under the strict rules. */
83
+ export function checkKeyedTarget(url: string, env: Env = process.env): TargetCheck {
84
+ let u: URL;
85
+ try {
86
+ u = new URL(url);
87
+ } catch {
88
+ return { ok: false, reason: "target is not a valid URL" };
89
+ }
90
+ const label = safeTargetLabel(url);
91
+ if (u.username || u.password) return { ok: false, reason: `${label}: credentials in the URL are refused` };
92
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
93
+ return { ok: false, reason: `${label}: only http and https are allowed` };
94
+ }
95
+ const host = u.hostname.toLowerCase();
96
+ if (LOOPBACK_HOSTS.has(host)) return { ok: true };
97
+ if (u.protocol === "http:") {
98
+ return { ok: false, reason: `${label}: plain http is allowed only for 127.0.0.1 and [::1]` };
99
+ }
100
+ const hostPort = `${host}:${u.port || "443"}`;
101
+ if (BUILTIN_PROVIDER_HOSTS.includes(hostPort)) return { ok: true };
102
+ const { hosts, invalid } = parseGatewayHosts(env);
103
+ if (invalid.length > 0) {
104
+ return { ok: false, reason: `${GATEWAY_HOSTS_ENV} has entries that are not host:port (${invalid.join(", ")}); fix them first` };
105
+ }
106
+ if (hosts.has(hostPort)) return { ok: true };
107
+ return { ok: false, reason: `${label}: ${hostPort} is not listed in ${GATEWAY_HOSTS_ENV}` };
108
+ }
109
+
110
+ /**
111
+ * fetch() for a request that carries a key. Unchanged when strict mode is
112
+ * off. In strict mode it refuses a target that fails checkKeyedTarget() and
113
+ * any redirect, throwing GatewayRefusal before or instead of following it.
114
+ */
115
+ export async function keyedFetch(
116
+ url: string,
117
+ init: RequestInit = {},
118
+ env: Env = process.env,
119
+ fetchImpl: typeof fetch = fetch,
120
+ ): Promise<Response> {
121
+ if (!gatewayStrict(env)) return fetchImpl(url, init);
122
+ const check = checkKeyedTarget(url, env);
123
+ if (!check.ok) throw new GatewayRefusal(`[ashlr] gateway strict mode refused ${check.reason}`);
124
+ const res = await fetchImpl(url, { ...init, redirect: "manual" });
125
+ if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
126
+ try { await res.body?.cancel(); } catch { /* nothing to drain */ }
127
+ throw new GatewayRefusal(`[ashlr] gateway strict mode refused a redirect from ${safeTargetLabel(url)}`);
128
+ }
129
+ return res;
130
+ }
131
+
132
+ /**
133
+ * Default-path rules for a keyed model call, applied whether or not strict
134
+ * mode is on: https to any host, or plain http only to 127.0.0.1 or [::1]
135
+ * (`localhost` by name does not count), and no credentials in the URL.
136
+ */
137
+ export function checkBaseTarget(url: string): TargetCheck {
138
+ let u: URL;
139
+ try {
140
+ u = new URL(url);
141
+ } catch {
142
+ return { ok: false, reason: "target is not a valid URL" };
143
+ }
144
+ const label = safeTargetLabel(url);
145
+ if (u.username || u.password) return { ok: false, reason: `${label}: credentials in the URL are refused` };
146
+ if (u.protocol === "https:") return { ok: true };
147
+ if (u.protocol === "http:") {
148
+ return LOOPBACK_HOSTS.has(u.hostname.toLowerCase())
149
+ ? { ok: true }
150
+ : { ok: false, reason: `${label}: plain http is allowed only for 127.0.0.1 and [::1]` };
151
+ }
152
+ return { ok: false, reason: `${label}: only http and https are allowed` };
153
+ }
154
+
155
+ /**
156
+ * fetch() for a keyed call on the default path. Always applies
157
+ * checkBaseTarget() and never follows a redirect (a 3xx is a refusal, since
158
+ * fetch forwards custom key headers to the redirect target). With
159
+ * ASHLR_GATEWAY_STRICT on, the strict host rules apply on top.
160
+ */
161
+ export async function safeKeyedFetch(
162
+ url: string,
163
+ init: RequestInit = {},
164
+ env: Env = process.env,
165
+ fetchImpl: typeof fetch = fetch,
166
+ ): Promise<Response> {
167
+ const base = checkBaseTarget(url);
168
+ if (!base.ok) throw new GatewayRefusal(`[ashlr] refused model endpoint ${base.reason}`);
169
+ if (gatewayStrict(env)) {
170
+ const strictCheck = checkKeyedTarget(url, env);
171
+ if (!strictCheck.ok) throw new GatewayRefusal(`[ashlr] gateway strict mode refused ${strictCheck.reason}`);
172
+ }
173
+ const res = await fetchImpl(url, { ...init, redirect: "manual" });
174
+ if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
175
+ try { await res.body?.cancel(); } catch { /* nothing to drain */ }
176
+ throw new GatewayRefusal(`[ashlr] refused a redirect from model endpoint ${safeTargetLabel(url)}`);
177
+ }
178
+ return res;
179
+ }
@@ -11,10 +11,12 @@
11
11
  import { readFile } from "fs/promises";
12
12
  import { homedir } from "os";
13
13
  import { join } from "path";
14
+ import { DEFAULT_ANTHROPIC_BASE_URL, anthropicBaseUrl } from "../_base-urls.ts";
14
15
  import type { LlmProvider, LlmSummarizeResult } from "./types.ts";
16
+ import { safeKeyedFetch } from "../_gateway-target.ts";
17
+ import { redactedBody } from "../_redact-keys.ts";
15
18
 
16
19
  const MODEL = "claude-haiku-4-5-20251001";
17
- const API_BASE = "https://api.anthropic.com";
18
20
  const ANTHROPIC_VERSION = "2023-06-01";
19
21
 
20
22
  function home(): string {
@@ -49,6 +51,10 @@ async function readClaudeCredentials(): Promise<string | null> {
49
51
  async function resolveApiKey(): Promise<string | null> {
50
52
  const env = process.env.ANTHROPIC_API_KEY;
51
53
  if (env && env.trim().length > 0) return env.trim();
54
+ // The Claude Code OAuth token is an Anthropic credential, not the user's
55
+ // gateway key: never hand it to a custom ANTHROPIC_BASE_URL (LiteLLM or
56
+ // any other proxy). Only the default Anthropic host may receive it.
57
+ if (anthropicBaseUrl() !== DEFAULT_ANTHROPIC_BASE_URL) return null;
52
58
  return readClaudeCredentials();
53
59
  }
54
60
 
@@ -81,7 +87,7 @@ export const anthropicProvider: LlmProvider = {
81
87
 
82
88
  let res: Response;
83
89
  try {
84
- res = await fetch(`${API_BASE}/v1/messages`, {
90
+ res = await safeKeyedFetch(`${anthropicBaseUrl()}/v1/messages`, {
85
91
  method: "POST",
86
92
  signal: ctl.signal,
87
93
  headers: {
@@ -101,8 +107,8 @@ export const anthropicProvider: LlmProvider = {
101
107
  }
102
108
 
103
109
  if (!res.ok) {
104
- const body = await res.text().catch(() => "");
105
- throw new Error(`Anthropic API error ${res.status}: ${body.slice(0, 200)}`);
110
+ const body = await redactedBody(res, [apiKey]);
111
+ throw new Error(`Anthropic API error ${res.status}: ${body}`);
106
112
  }
107
113
 
108
114
  const latencyMs = Date.now() - t0;