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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +49 -8
- package/commands/ashlr-doctor.md +29 -0
- package/commands/ashlr-status.md +1 -1
- package/docs/PHANTOM-USAGE.md +58 -0
- package/docs/architecture.md +5 -5
- package/docs/ashlr-handshake.md +36 -0
- package/docs/assets/og.svg +1 -1
- package/docs/benchmarks-v2.json +226 -177
- package/docs/benchmarks.md +20 -10
- package/docs/competitive-wozcode.md +2 -2
- package/docs/embeddings-gateway.md +39 -0
- package/docs/index.html +3 -3
- package/docs/legal.md +1 -1
- package/docs/llm-providers.md +15 -1
- package/docs/multi-host-mcp.md +25 -3
- package/docs/phantom-preflight.md +23 -0
- package/docs/team-genome.md +2 -1
- package/hooks/_hook-timing-ledger.ts +2 -9
- package/hooks/hooks.json +3 -106
- package/hooks/posttooluse-stale-result.ts +3 -0
- package/hooks/pretooluse-common.ts +1 -1
- package/hooks/pretooluse-notebookedit.ts +1 -1
- package/hooks/pretooluse-write.ts +1 -1
- package/hooks/session-start.ts +48 -26
- package/package.json +9 -4
- package/ports/cursor/mcp.json +1 -1
- package/scripts/_read-thresholds.ts +119 -0
- package/scripts/ashlr-mcp.mjs +15 -0
- package/scripts/ashlr-mcp.ts +3 -0
- package/scripts/ashlr-plugin.mjs +59 -0
- package/scripts/bench-read-real.ts +90 -0
- package/scripts/bootstrap.mjs +14 -4
- package/scripts/cli.ts +35 -10
- package/scripts/context-status.ts +7 -2
- package/scripts/deploy-smoke.sh +8 -4
- package/scripts/doctor.ts +171 -42
- package/scripts/genome-auto-consolidate.ts +4 -2
- package/scripts/genome-init.ts +3 -3
- package/scripts/genome-key.ts +64 -7
- package/scripts/handshake.mjs +51 -0
- package/scripts/hook-bootstrap.mjs +170 -17
- package/scripts/mcp-entrypoint.ts +3 -0
- package/scripts/ollama-setup.ts +5 -4
- package/scripts/run-benchmark.ts +28 -18
- package/scripts/upgrade-flow.ts +9 -14
- package/servers/_base-urls.ts +48 -0
- package/servers/_companions.ts +258 -0
- package/servers/_cwd-clamp.ts +38 -1
- package/servers/_edit-match.ts +12 -2
- package/servers/_embedding-model.ts +77 -0
- package/servers/_gateway-target.ts +179 -0
- package/servers/_llm-providers/anthropic.ts +10 -4
- package/servers/_llm-providers/cloud.ts +9 -7
- package/servers/_llm-providers/index.ts +8 -6
- package/servers/_llm-providers/local.ts +9 -5
- package/servers/_mcp-host.ts +6 -0
- package/servers/_miss-tracker.ts +121 -0
- package/servers/_phantom-exec.ts +135 -0
- package/servers/_phantom-preflight.ts +130 -0
- package/servers/_pid-alive.ts +40 -0
- package/servers/_pro-token-keychain.ts +149 -0
- package/servers/_redact-keys.ts +75 -0
- package/servers/_rg-text.ts +111 -0
- package/servers/_router.ts +20 -2
- package/servers/_run-with-timeout.ts +56 -12
- package/servers/_test-watch.ts +1 -10
- package/servers/_token-measure.ts +3 -1
- package/servers/_tool-base.ts +6 -0
- package/servers/bash-server.ts +1 -10
- package/servers/edit-server.ts +24 -1
- package/servers/efficiency-server.ts +12 -2
- package/servers/genome-server-handlers.ts +2 -2
- package/servers/genome-server.ts +17 -17
- package/servers/grep-server.ts +6 -2
- package/servers/orient-server-handlers.ts +3 -2
- package/servers/orient-server.ts +16 -9
- package/servers/read-server.ts +1 -1
- package/servers/write-server-handlers.ts +10 -2
|
@@ -20,6 +20,8 @@ import { readFile, stat } from "fs/promises";
|
|
|
20
20
|
import { homedir } from "os";
|
|
21
21
|
import { join } from "path";
|
|
22
22
|
import type { LlmProvider, LlmSummarizeResult } from "./types.ts";
|
|
23
|
+
import { safeKeyedFetch } from "../_gateway-target.ts";
|
|
24
|
+
import { redactedBody } from "../_redact-keys.ts";
|
|
23
25
|
|
|
24
26
|
// ---------------------------------------------------------------------------
|
|
25
27
|
// Constants
|
|
@@ -132,7 +134,7 @@ export const cloudProvider: LlmProvider = {
|
|
|
132
134
|
|
|
133
135
|
let res: Response;
|
|
134
136
|
try {
|
|
135
|
-
res = await
|
|
137
|
+
res = await safeKeyedFetch(url, {
|
|
136
138
|
method: "POST",
|
|
137
139
|
signal: ctl.signal,
|
|
138
140
|
headers: {
|
|
@@ -152,11 +154,11 @@ export const cloudProvider: LlmProvider = {
|
|
|
152
154
|
|
|
153
155
|
// 429 — rate limit (per-minute bucket) OR daily cap exhausted
|
|
154
156
|
if (res.status === 429) {
|
|
155
|
-
const body = await res
|
|
157
|
+
const body = await redactedBody(res, [token]);
|
|
156
158
|
const retryHeader = res.headers.get("retry-after");
|
|
157
159
|
const retryAfterSecs = retryHeader ? parseInt(retryHeader, 10) : undefined;
|
|
158
160
|
throw new CloudRateLimitError(
|
|
159
|
-
`Cloud LLM rate limit: ${body
|
|
161
|
+
`Cloud LLM rate limit: ${body}`,
|
|
160
162
|
429,
|
|
161
163
|
Number.isFinite(retryAfterSecs) ? retryAfterSecs : undefined,
|
|
162
164
|
);
|
|
@@ -164,13 +166,13 @@ export const cloudProvider: LlmProvider = {
|
|
|
164
166
|
|
|
165
167
|
// 5xx — backend error; let dispatcher fall through to onnx/local/snipCompact
|
|
166
168
|
if (res.status >= 500) {
|
|
167
|
-
const body = await res
|
|
168
|
-
throw new Error(`Cloud LLM error ${res.status}: ${body
|
|
169
|
+
const body = await redactedBody(res, [token]);
|
|
170
|
+
throw new Error(`Cloud LLM error ${res.status}: ${body}`);
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
if (!res.ok) {
|
|
172
|
-
const body = await res
|
|
173
|
-
throw new Error(`Cloud LLM error ${res.status}: ${body
|
|
174
|
+
const body = await redactedBody(res, [token]);
|
|
175
|
+
throw new Error(`Cloud LLM error ${res.status}: ${body}`);
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
const latencyMs = Date.now() - t0;
|
|
@@ -34,9 +34,10 @@ import { bumpSummarization } from "../_stats.ts";
|
|
|
34
34
|
import { costForLLM } from "../_pricing.ts";
|
|
35
35
|
import { anthropicProvider, _resetAnthropicAvailabilityCache } from "./anthropic.ts";
|
|
36
36
|
import { onnxProvider } from "./onnx.ts";
|
|
37
|
-
import { localProvider, _resetLocalAvailabilityCache } from "./local.ts";
|
|
37
|
+
import { DEFAULT_LOCAL_LLM_URL, localProvider, _resetLocalAvailabilityCache } from "./local.ts";
|
|
38
38
|
import { cloudProvider, _resetCloudAvailabilityCache } from "./cloud.ts";
|
|
39
39
|
import type { LlmProvider, ProviderName } from "./types.ts";
|
|
40
|
+
import { safeKeyedFetch, safeTargetLabel } from "../_gateway-target.ts";
|
|
40
41
|
|
|
41
42
|
export type { LlmProvider, ProviderName };
|
|
42
43
|
export { _resetAnthropicAvailabilityCache, _resetLocalAvailabilityCache, _resetCloudAvailabilityCache };
|
|
@@ -277,15 +278,16 @@ export async function summarizeIfLarge(
|
|
|
277
278
|
if (summary == null) {
|
|
278
279
|
await logEvent("tool_fallback", { tool: opts.toolName, reason: "llm-unreachable" });
|
|
279
280
|
// Per v1.22 review: when the SELECTED provider is local AND it failed,
|
|
280
|
-
// surface a one-line stderr warning that names the
|
|
281
|
-
// configured
|
|
281
|
+
// surface a one-line stderr warning that names the endpoint the user
|
|
282
|
+
// configured (scheme://host:port only, so credentials, path and query
|
|
283
|
+
// in ASHLR_LLM_URL never reach stderr). Without this, a typo in ASHLR_LLM_URL silently degrades
|
|
282
284
|
// every summarization to truncation with the only signal buried inside
|
|
283
285
|
// tool output. Only logged once per process to avoid spam.
|
|
284
286
|
if (provider.name === "local" && !_localUnreachableWarned) {
|
|
285
287
|
_localUnreachableWarned = true;
|
|
286
|
-
const url = process.env.ASHLR_LLM_URL ??
|
|
288
|
+
const url = process.env.ASHLR_LLM_URL ?? DEFAULT_LOCAL_LLM_URL;
|
|
287
289
|
process.stderr.write(
|
|
288
|
-
`[ashlr] WARN: local LLM at ${url} unreachable; falling back to truncation. ` +
|
|
290
|
+
`[ashlr] WARN: local LLM at ${safeTargetLabel(url)} unreachable; falling back to truncation. ` +
|
|
289
291
|
`Verify the endpoint, or unset ASHLR_LLM_URL to let auto-selection prefer Anthropic.\n`,
|
|
290
292
|
);
|
|
291
293
|
}
|
|
@@ -315,7 +317,7 @@ async function callLegacyEndpoint(rawText: string, opts: SummarizeOpts): Promise
|
|
|
315
317
|
const ctl = new AbortController();
|
|
316
318
|
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
317
319
|
try {
|
|
318
|
-
const res = await
|
|
320
|
+
const res = await safeKeyedFetch(`${url}/chat/completions`, {
|
|
319
321
|
method: "POST",
|
|
320
322
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
321
323
|
signal: ctl.signal,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Local LLM provider — LM Studio / Ollama via OpenAI-compat API.
|
|
3
3
|
*
|
|
4
4
|
* Preserves the existing _summarize.ts local-LLM logic verbatim:
|
|
5
|
-
* - ASHLR_LLM_URL (default: http://
|
|
5
|
+
* - ASHLR_LLM_URL (default: http://127.0.0.1:1234/v1)
|
|
6
6
|
* - ASHLR_LLM_KEY (default: "local-llm")
|
|
7
7
|
* - ASHLR_LLM_MODEL (default: "qwen/qwen3-coder-30b@8bit")
|
|
8
8
|
*
|
|
@@ -11,16 +11,20 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { LlmProvider, LlmSummarizeResult } from "./types.ts";
|
|
14
|
+
import { safeKeyedFetch } from "../_gateway-target.ts";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_LOCAL_LLM_URL = "http://127.0.0.1:1234/v1";
|
|
14
17
|
|
|
15
18
|
function localEndpoint(): string {
|
|
16
19
|
// Mirror the llmEndpoint() logic from the old _summarize.ts — pro-token
|
|
17
20
|
// auto-routing uses the ashlr cloud; explicit ASHLR_LLM_URL wins; default
|
|
18
|
-
// is LM Studio at
|
|
21
|
+
// is LM Studio at 127.0.0.1:1234 (an IP literal: keyed calls follow the
|
|
22
|
+
// default-path gateway rules, which do not accept `localhost` by name).
|
|
19
23
|
if (process.env.ASHLR_PRO_TOKEN && !process.env.ASHLR_LLM_URL) {
|
|
20
24
|
const base = process.env.ASHLR_API_URL ?? "https://api.ashlr.ai";
|
|
21
25
|
return `${base}/llm`;
|
|
22
26
|
}
|
|
23
|
-
return process.env.ASHLR_LLM_URL ??
|
|
27
|
+
return process.env.ASHLR_LLM_URL ?? DEFAULT_LOCAL_LLM_URL;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
// Availability cache: { available, expiresAt }
|
|
@@ -46,7 +50,7 @@ export const localProvider: LlmProvider = {
|
|
|
46
50
|
const t = setTimeout(() => ctl.abort(), 2_000);
|
|
47
51
|
|
|
48
52
|
try {
|
|
49
|
-
const res = await
|
|
53
|
+
const res = await safeKeyedFetch(`${url}/models`, {
|
|
50
54
|
signal: ctl.signal,
|
|
51
55
|
headers: { Authorization: `Bearer ${process.env.ASHLR_LLM_KEY ?? "local-llm"}` },
|
|
52
56
|
});
|
|
@@ -81,7 +85,7 @@ export const localProvider: LlmProvider = {
|
|
|
81
85
|
const t0 = Date.now();
|
|
82
86
|
let res: Response;
|
|
83
87
|
try {
|
|
84
|
-
res = await
|
|
88
|
+
res = await safeKeyedFetch(`${url}/chat/completions`, {
|
|
85
89
|
method: "POST",
|
|
86
90
|
signal: ctl.signal,
|
|
87
91
|
headers: {
|
package/servers/_mcp-host.ts
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* Cline (cline_mcp_settings.json) → "cline"
|
|
22
22
|
* Claude Desktop (claude_desktop_config.json) → "claude-desktop"
|
|
23
23
|
* Codex CLI (~/.codex/config.toml) → "codex-cli"
|
|
24
|
+
* Gemini CLI (~/.gemini/settings.json) → "gemini-cli"
|
|
25
|
+
* Cursor (.cursor/mcp.json) → "cursor"
|
|
24
26
|
* Anything else → "generic"
|
|
25
27
|
*
|
|
26
28
|
* Backwards compat: when unset, we infer "claude-code" so existing installs
|
|
@@ -33,6 +35,8 @@ export type McpHost =
|
|
|
33
35
|
| "cline"
|
|
34
36
|
| "claude-desktop"
|
|
35
37
|
| "codex-cli"
|
|
38
|
+
| "gemini-cli"
|
|
39
|
+
| "cursor"
|
|
36
40
|
| "generic";
|
|
37
41
|
|
|
38
42
|
const KNOWN_HOSTS: ReadonlySet<McpHost> = new Set([
|
|
@@ -40,6 +44,8 @@ const KNOWN_HOSTS: ReadonlySet<McpHost> = new Set([
|
|
|
40
44
|
"cline",
|
|
41
45
|
"claude-desktop",
|
|
42
46
|
"codex-cli",
|
|
47
|
+
"gemini-cli",
|
|
48
|
+
"cursor",
|
|
43
49
|
"generic",
|
|
44
50
|
]);
|
|
45
51
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* _miss-tracker.ts: compression miss tracking (self-tuning P1).
|
|
3
|
+
*
|
|
4
|
+
* A "miss" is a compressed ashlr__read / ashlr__grep result for a target that
|
|
5
|
+
* is followed by a native Read / Grep of the same target within
|
|
6
|
+
* MISS_WINDOW_TURNS turns. That re-read signals the compact output was not
|
|
7
|
+
* enough. Counts feed the per-repo tuner (P2).
|
|
8
|
+
*
|
|
9
|
+
* Privacy: local only. Targets are kept only as an 8-char hash in the
|
|
10
|
+
* per-session pending file; the persistent stats file holds counts keyed by
|
|
11
|
+
* ashlr tool and file extension, never paths or content.
|
|
12
|
+
*
|
|
13
|
+
* Off switches: ASHLR_TUNING=off or ASHLR_SESSION_LOG=0. Best-effort, never throws.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
17
|
+
import { extname, join, resolve } from "path";
|
|
18
|
+
|
|
19
|
+
import { sha8 } from "./_history-tracker";
|
|
20
|
+
|
|
21
|
+
/** A native re-read within this many turns of a compressed result counts as a miss. */
|
|
22
|
+
export const MISS_WINDOW_TURNS = 3;
|
|
23
|
+
|
|
24
|
+
const ASHLR_TOOLS: Record<string, "read" | "grep"> = {
|
|
25
|
+
ashlr__read: "read",
|
|
26
|
+
ashlr__grep: "grep",
|
|
27
|
+
"mcp__ashlr-efficiency__ashlr__read": "read",
|
|
28
|
+
"mcp__ashlr-efficiency__ashlr__grep": "grep",
|
|
29
|
+
mcp__plugin_ashlr_ashlr__ashlr__read: "read",
|
|
30
|
+
mcp__plugin_ashlr_ashlr__ashlr__grep: "grep",
|
|
31
|
+
};
|
|
32
|
+
const NATIVE_TOOLS: Record<string, "read" | "grep"> = { Read: "read", Grep: "grep" };
|
|
33
|
+
|
|
34
|
+
export interface MissCounts { compressed: number; misses: number }
|
|
35
|
+
/** stats[tool]["." + ext or "(none)"] */
|
|
36
|
+
export type MissStats = Record<string, Record<string, MissCounts>>;
|
|
37
|
+
|
|
38
|
+
interface Pending { t: string; tool: string; ext: string; turn: number; missed?: boolean }
|
|
39
|
+
|
|
40
|
+
export function trackingEnabled(): boolean {
|
|
41
|
+
return process.env.ASHLR_TUNING !== "off" && process.env.ASHLR_SESSION_LOG !== "0";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function missStatsPath(homeDir: string): string {
|
|
45
|
+
return join(homeDir, ".ashlr", "miss-stats.json");
|
|
46
|
+
}
|
|
47
|
+
function pendingPath(homeDir: string, sessionId: string): string {
|
|
48
|
+
return join(homeDir, ".ashlr", "miss-pending", `${sessionId}.json`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readJson<T>(p: string, fallback: T): T {
|
|
52
|
+
try { return existsSync(p) ? (JSON.parse(readFileSync(p, "utf-8")) as T) : fallback; } catch { return fallback; }
|
|
53
|
+
}
|
|
54
|
+
function writeJson(p: string, v: unknown): void {
|
|
55
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
56
|
+
writeFileSync(p, JSON.stringify(v), "utf-8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Target path from a Read/Grep or ashlr__read/ashlr__grep tool_input. */
|
|
60
|
+
export function targetOf(input: unknown, cwd: string = process.cwd()): string | null {
|
|
61
|
+
if (!input || typeof input !== "object") return null;
|
|
62
|
+
const i = input as Record<string, unknown>;
|
|
63
|
+
const p = i.file_path ?? i.path;
|
|
64
|
+
if (typeof p !== "string" || !p.trim()) return null;
|
|
65
|
+
return resolve(cwd, p);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function readMissStats(homeDir: string): MissStats {
|
|
69
|
+
return readJson<MissStats>(missStatsPath(homeDir), {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Observe one completed tool call. `turn` is the session turn index of this
|
|
74
|
+
* call. Returns "compressed", "miss", or null (nothing recorded).
|
|
75
|
+
*/
|
|
76
|
+
export function observeToolCall(
|
|
77
|
+
toolName: string,
|
|
78
|
+
toolInput: unknown,
|
|
79
|
+
turn: number,
|
|
80
|
+
sessionId: string,
|
|
81
|
+
homeDir: string,
|
|
82
|
+
cwd: string = process.cwd(),
|
|
83
|
+
): "compressed" | "miss" | null {
|
|
84
|
+
if (!trackingEnabled()) return null;
|
|
85
|
+
try {
|
|
86
|
+
const kind = ASHLR_TOOLS[toolName] ?? NATIVE_TOOLS[toolName];
|
|
87
|
+
if (!kind) return null;
|
|
88
|
+
const target = targetOf(toolInput, cwd);
|
|
89
|
+
if (!target) return null;
|
|
90
|
+
const t = sha8(target);
|
|
91
|
+
const pp = pendingPath(homeDir, sessionId);
|
|
92
|
+
const pending = readJson<Pending[]>(pp, []).filter((e) => turn - e.turn <= MISS_WINDOW_TURNS);
|
|
93
|
+
const stats = readMissStats(homeDir);
|
|
94
|
+
const bump = (tool: string, ext: string, key: keyof MissCounts) => {
|
|
95
|
+
const row = (stats[tool] ??= {});
|
|
96
|
+
const c = (row[ext] ??= { compressed: 0, misses: 0 });
|
|
97
|
+
c[key]++;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
let outcome: "compressed" | "miss" | null = null;
|
|
101
|
+
if (ASHLR_TOOLS[toolName]) {
|
|
102
|
+
const tool = `ashlr__${kind}`;
|
|
103
|
+
const ext = extname(target).toLowerCase() || "(none)";
|
|
104
|
+
pending.push({ t, tool, ext, turn });
|
|
105
|
+
bump(tool, ext, "compressed");
|
|
106
|
+
outcome = "compressed";
|
|
107
|
+
} else {
|
|
108
|
+
const hit = pending.find((e) => e.t === t && !e.missed && e.turn < turn);
|
|
109
|
+
if (hit) {
|
|
110
|
+
hit.missed = true;
|
|
111
|
+
bump(hit.tool, hit.ext, "misses");
|
|
112
|
+
outcome = "miss";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
writeJson(pp, pending);
|
|
116
|
+
if (outcome) writeJson(missStatsPath(homeDir), stats);
|
|
117
|
+
return outcome;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value-free detection of a `phantom exec -- <cmd>` session.
|
|
3
|
+
*
|
|
4
|
+
* `phantom exec` hands its child fresh `phm_` session tokens in place of real
|
|
5
|
+
* keys, points the provider SDK base URLs at a local proxy
|
|
6
|
+
* (`http://127.0.0.1:<port>/<route>/_phantom/<token>/`) and sets
|
|
7
|
+
* PHANTOM_PROXY_PORT, PHANTOM_PROXY_TOKEN and PHANTOM_SESSION_KEYS. The proxy
|
|
8
|
+
* swaps the token for the real key on the way out.
|
|
9
|
+
*
|
|
10
|
+
* This module only looks at variable NAMES, the `phm_` / `phm:` prefixes and
|
|
11
|
+
* the base URL host and port. It never resolves a reference, never runs
|
|
12
|
+
* `phantom reveal`, never returns a key value, and redacts the per-session
|
|
13
|
+
* proxy token from any URL it reports.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
type Env = Record<string, string | undefined>;
|
|
17
|
+
|
|
18
|
+
/** Keys the plugin's scripts may send to a provider. */
|
|
19
|
+
export const PROVIDER_KEY_VARS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] as const;
|
|
20
|
+
/** Base URL variables `phantom exec` may set for those providers. */
|
|
21
|
+
export const PROVIDER_BASE_URL_VARS = ["ANTHROPIC_BASE_URL", "OPENAI_BASE_URL"] as const;
|
|
22
|
+
|
|
23
|
+
export type KeyKind = "unset" | "placeholder" | "ref" | "plain";
|
|
24
|
+
|
|
25
|
+
export interface PhantomExecReport {
|
|
26
|
+
/** True when PHANTOM_PROXY_PORT is a valid port and PHANTOM_PROXY_TOKEN is present. */
|
|
27
|
+
inSession: boolean;
|
|
28
|
+
proxyPort: number | null;
|
|
29
|
+
/** Per provider key: what kind of value it holds (never the value). */
|
|
30
|
+
keys: Record<string, KeyKind>;
|
|
31
|
+
/** Base URLs that point at this session's local proxy, token redacted. */
|
|
32
|
+
proxiedBaseUrls: Record<string, string>;
|
|
33
|
+
/** Plain-language warnings, value-free. */
|
|
34
|
+
warnings: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Kind of value held by a key variable, without exposing it. */
|
|
38
|
+
export function keyKind(value: string | undefined): KeyKind {
|
|
39
|
+
const v = (value ?? "").trim();
|
|
40
|
+
if (!v) return "unset";
|
|
41
|
+
if (v.startsWith("phm_")) return "placeholder";
|
|
42
|
+
if (v.startsWith("phm:")) return "ref";
|
|
43
|
+
return "plain";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Replace the `/_phantom/<token>/` segment so a printed URL carries no token. */
|
|
47
|
+
export function redactProxyUrl(url: string): string {
|
|
48
|
+
return url.replace(/\/_phantom\/[^/]+/g, "/_phantom/<redacted>");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validPort(raw: string | undefined): number | null {
|
|
52
|
+
if (!raw || !/^\d{1,5}$/.test(raw.trim())) return null;
|
|
53
|
+
const n = Number(raw.trim());
|
|
54
|
+
return n >= 1 && n <= 65535 ? n : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when `url` is a loopback http URL on `port`. */
|
|
58
|
+
function pointsAtProxy(url: string, port: number): boolean {
|
|
59
|
+
try {
|
|
60
|
+
const u = new URL(url);
|
|
61
|
+
const host = u.hostname.replace(/^\[|\]$/g, "");
|
|
62
|
+
return (
|
|
63
|
+
u.protocol === "http:" &&
|
|
64
|
+
(host === "127.0.0.1" || host === "localhost" || host === "::1") &&
|
|
65
|
+
Number(u.port) === port
|
|
66
|
+
);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function detectPhantomExec(env: Env = process.env): PhantomExecReport {
|
|
73
|
+
const proxyPort = validPort(env["PHANTOM_PROXY_PORT"]);
|
|
74
|
+
const inSession = proxyPort !== null && !!(env["PHANTOM_PROXY_TOKEN"] ?? "").trim();
|
|
75
|
+
const keys: Record<string, KeyKind> = {};
|
|
76
|
+
for (const name of PROVIDER_KEY_VARS) keys[name] = keyKind(env[name]);
|
|
77
|
+
const proxiedBaseUrls: Record<string, string> = {};
|
|
78
|
+
if (inSession && proxyPort !== null) {
|
|
79
|
+
for (const name of PROVIDER_BASE_URL_VARS) {
|
|
80
|
+
const v = (env[name] ?? "").trim();
|
|
81
|
+
if (v && pointsAtProxy(v, proxyPort)) proxiedBaseUrls[name] = redactProxyUrl(v);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const warnings: string[] = [];
|
|
86
|
+
const baseFor: Record<string, string> = {
|
|
87
|
+
ANTHROPIC_API_KEY: "ANTHROPIC_BASE_URL",
|
|
88
|
+
OPENAI_API_KEY: "OPENAI_BASE_URL",
|
|
89
|
+
};
|
|
90
|
+
for (const name of PROVIDER_KEY_VARS) {
|
|
91
|
+
const kind = keys[name];
|
|
92
|
+
if (kind === "ref") {
|
|
93
|
+
warnings.push(
|
|
94
|
+
`${name} is a phm: reference. The plugin does not resolve references; run under \`phantom exec -- <cmd>\` so the variable holds a session placeholder routed by the proxy.`,
|
|
95
|
+
);
|
|
96
|
+
} else if (kind === "placeholder" && !inSession) {
|
|
97
|
+
warnings.push(
|
|
98
|
+
`${name} is a phm_ placeholder but this is not a phantom exec session, so a provider would reject it. Run under \`phantom exec -- <cmd>\`.`,
|
|
99
|
+
);
|
|
100
|
+
} else if (kind === "placeholder" && !proxiedBaseUrls[baseFor[name]!]) {
|
|
101
|
+
warnings.push(
|
|
102
|
+
`${name} is a phm_ placeholder but ${baseFor[name]} does not point at this session's proxy, so the placeholder would go to the provider directly. Check \`phantom why ${name}\`.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { inSession, proxyPort, keys, proxiedBaseUrls, warnings };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One-line, value-free summary for doctor and the bench scripts. */
|
|
110
|
+
export function describePhantomExec(r: PhantomExecReport): string {
|
|
111
|
+
const placeholders = Object.entries(r.keys)
|
|
112
|
+
.filter(([, k]) => k === "placeholder")
|
|
113
|
+
.map(([n]) => n);
|
|
114
|
+
if (!r.inSession) {
|
|
115
|
+
return placeholders.length || Object.values(r.keys).includes("ref")
|
|
116
|
+
? "not a phantom exec session (provider keys hold phm values; see warnings)"
|
|
117
|
+
: "not a phantom exec session";
|
|
118
|
+
}
|
|
119
|
+
const routed = Object.keys(r.proxiedBaseUrls);
|
|
120
|
+
return `phantom exec session on 127.0.0.1:${r.proxyPort}; placeholders: ${placeholders.join(", ") || "none"}; proxied: ${routed.join(", ") || "none"}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether a provider key is usable for a live call from this process: a plain
|
|
125
|
+
* key, or a placeholder inside a phantom exec session whose base URL routes
|
|
126
|
+
* through the proxy. Unset keys and phm: refs are not usable.
|
|
127
|
+
*/
|
|
128
|
+
export function providerKeyUsable(name: (typeof PROVIDER_KEY_VARS)[number], env: Env = process.env): boolean {
|
|
129
|
+
const r = detectPhantomExec(env);
|
|
130
|
+
const kind = r.keys[name];
|
|
131
|
+
if (kind === "plain") return true;
|
|
132
|
+
if (kind !== "placeholder") return false;
|
|
133
|
+
const base = name === "ANTHROPIC_API_KEY" ? "ANTHROPIC_BASE_URL" : "OPENAI_BASE_URL";
|
|
134
|
+
return r.inSession && !!r.proxiedBaseUrls[base];
|
|
135
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in Phantom agent preflight for `ashlr doctor`, following phantom-secrets
|
|
3
|
+
* docs/agent-preflight-contract.md (schema_version 1). It runs
|
|
4
|
+
* `phantom agent doctor --json --strict`, which reads only project files and
|
|
5
|
+
* environment variables (no vault, no keychain) and prints a value-free
|
|
6
|
+
* verdict. `--strict` exits 0 ready, 2 degraded, 1 blocked, 3 could not
|
|
7
|
+
* evaluate.
|
|
8
|
+
*
|
|
9
|
+
* Off unless ASHLR_PHANTOM_PREFLIGHT is set:
|
|
10
|
+
* 1 / true / on blocked is a fail; degraded or unknown is a warning
|
|
11
|
+
* strict anything but ready is a fail
|
|
12
|
+
* A missing phantom, or one without `agent doctor --json`, is only a warning.
|
|
13
|
+
* Output is the verdict and kebab-case check ids; never names or values.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from "child_process";
|
|
17
|
+
|
|
18
|
+
export type PreflightMode = "off" | "on" | "strict";
|
|
19
|
+
export type Verdict = "ready" | "degraded" | "blocked" | "unknown";
|
|
20
|
+
export type PreflightRunner = () => {
|
|
21
|
+
status: number | null;
|
|
22
|
+
stdout: string;
|
|
23
|
+
missing: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface PreflightResult {
|
|
27
|
+
status: "ok" | "warn" | "fail";
|
|
28
|
+
verdict: Verdict | null;
|
|
29
|
+
/** Ids of the critical checks (`wrap.blocked_by`). Ids only. */
|
|
30
|
+
blockedBy: string[];
|
|
31
|
+
detail: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function preflightMode(
|
|
35
|
+
env: Record<string, string | undefined> = process.env,
|
|
36
|
+
): PreflightMode {
|
|
37
|
+
const v = (env["ASHLR_PHANTOM_PREFLIGHT"] ?? "").trim().toLowerCase();
|
|
38
|
+
if (v === "strict") return "strict";
|
|
39
|
+
if (v === "1" || v === "true" || v === "on") return "on";
|
|
40
|
+
return "off";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const defaultPreflightRunner: PreflightRunner = () => {
|
|
44
|
+
try {
|
|
45
|
+
const r = spawnSync("phantom", ["agent", "doctor", "--json", "--strict"], {
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
timeout: 15_000,
|
|
48
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
49
|
+
});
|
|
50
|
+
// Some Bun versions set `error` on a normal exit, so trust `status` first.
|
|
51
|
+
const missing =
|
|
52
|
+
r.status === null &&
|
|
53
|
+
(r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
54
|
+
return { status: r.status ?? null, stdout: r.stdout ?? "", missing };
|
|
55
|
+
} catch {
|
|
56
|
+
return { status: null, stdout: "", missing: true };
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const EXIT_CODES = new Set([0, 1, 2, 3]);
|
|
61
|
+
const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
62
|
+
const VERDICTS: readonly Verdict[] = [
|
|
63
|
+
"ready",
|
|
64
|
+
"degraded",
|
|
65
|
+
"blocked",
|
|
66
|
+
"unknown",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function parse(
|
|
70
|
+
stdout: string,
|
|
71
|
+
): { verdict: Verdict; blockedBy: string[] } | null {
|
|
72
|
+
try {
|
|
73
|
+
const doc = JSON.parse(stdout) as {
|
|
74
|
+
schema_version?: unknown;
|
|
75
|
+
kind?: unknown;
|
|
76
|
+
verdict?: unknown;
|
|
77
|
+
wrap?: { blocked_by?: unknown };
|
|
78
|
+
};
|
|
79
|
+
if (doc.kind !== "phantom.agent.preflight" || doc.schema_version !== 1)
|
|
80
|
+
return null;
|
|
81
|
+
if (!VERDICTS.includes(doc.verdict as Verdict)) return null;
|
|
82
|
+
const ids = Array.isArray(doc.wrap?.blocked_by) ? doc.wrap.blocked_by : [];
|
|
83
|
+
const blockedBy = ids.filter(
|
|
84
|
+
(id): id is string => typeof id === "string" && ID.test(id),
|
|
85
|
+
);
|
|
86
|
+
return { verdict: doc.verdict as Verdict, blockedBy };
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Run the preflight; null when it is off. */
|
|
93
|
+
export function runPhantomPreflight(
|
|
94
|
+
mode: PreflightMode,
|
|
95
|
+
run: PreflightRunner = defaultPreflightRunner,
|
|
96
|
+
): PreflightResult | null {
|
|
97
|
+
if (mode === "off") return null;
|
|
98
|
+
const r = run();
|
|
99
|
+
if (r.missing) {
|
|
100
|
+
return {
|
|
101
|
+
status: "warn",
|
|
102
|
+
verdict: null,
|
|
103
|
+
blockedBy: [],
|
|
104
|
+
detail: "skipped: phantom is not installed",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const parsed = parse(r.stdout);
|
|
108
|
+
if (!parsed || r.status === null || !EXIT_CODES.has(r.status)) {
|
|
109
|
+
return {
|
|
110
|
+
status: "warn",
|
|
111
|
+
verdict: null,
|
|
112
|
+
blockedBy: [],
|
|
113
|
+
detail: "skipped: this phantom has no `agent doctor --json` (schema 1)",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const { verdict, blockedBy } = parsed;
|
|
117
|
+
const status =
|
|
118
|
+
verdict === "ready"
|
|
119
|
+
? "ok"
|
|
120
|
+
: verdict === "blocked" || mode === "strict"
|
|
121
|
+
? "fail"
|
|
122
|
+
: "warn";
|
|
123
|
+
const ids = blockedBy.length ? ` (${blockedBy.join(", ")})` : "";
|
|
124
|
+
return {
|
|
125
|
+
status,
|
|
126
|
+
verdict,
|
|
127
|
+
blockedBy,
|
|
128
|
+
detail: `${verdict}${verdict === "blocked" ? ids : ""}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* _pid-alive.ts — "is this PID still a running process?"
|
|
3
|
+
*
|
|
4
|
+
* `process.kill(pid, 0)` succeeds for a zombie: a process that has exited but
|
|
5
|
+
* whose parent has not reaped it. Containers whose PID 1 is not an init that
|
|
6
|
+
* reaps orphans (common for agent sandboxes) keep every orphaned exit as a
|
|
7
|
+
* zombie, so a kill-only check reports long-dead background jobs and lock
|
|
8
|
+
* owners as alive forever. On Linux, read the state letter from
|
|
9
|
+
* /proc/<pid>/stat and treat Z (zombie) and X (dead) as gone.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "fs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True when `pid` names a live, non-zombie process. EPERM (the process exists
|
|
16
|
+
* but belongs to another user) counts as alive.
|
|
17
|
+
*/
|
|
18
|
+
export function pidAlive(pid: number): boolean {
|
|
19
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if ((error as NodeJS.ErrnoException).code !== "EPERM") return false;
|
|
24
|
+
}
|
|
25
|
+
return !isLinuxZombie(pid);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isLinuxZombie(pid: number): boolean {
|
|
29
|
+
if (process.platform !== "linux") return false;
|
|
30
|
+
try {
|
|
31
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
32
|
+
// Format: "pid (comm) S ...". comm may contain spaces or parentheses, so
|
|
33
|
+
// the state is the first field after the last ")".
|
|
34
|
+
const state = stat.slice(stat.lastIndexOf(")") + 2, stat.lastIndexOf(")") + 3);
|
|
35
|
+
return state === "Z" || state === "X";
|
|
36
|
+
} catch {
|
|
37
|
+
// /proc unreadable (hidepid, exotic mounts): fall back to the kill result.
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|