llm-cli-gateway 1.4.0 → 1.5.13
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/CHANGELOG.md +135 -1
- package/README.md +358 -15
- package/dist/approval-manager.d.ts +1 -1
- package/dist/async-job-manager.d.ts +32 -2
- package/dist/async-job-manager.js +101 -16
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +46 -0
- package/dist/cli-updater.d.ts +19 -2
- package/dist/cli-updater.js +110 -7
- package/dist/codex-json-parser.d.ts +34 -0
- package/dist/codex-json-parser.js +105 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +167 -0
- package/dist/doctor.d.ts +110 -0
- package/dist/doctor.js +280 -0
- package/dist/endpoint-exposure.d.ts +22 -0
- package/dist/endpoint-exposure.js +231 -0
- package/dist/entrypoint-url.d.ts +1 -0
- package/dist/entrypoint-url.js +5 -0
- package/dist/executor.d.ts +9 -1
- package/dist/executor.js +52 -17
- package/dist/flight-recorder.d.ts +3 -1
- package/dist/flight-recorder.js +31 -2
- package/dist/gateway-server.d.ts +2 -0
- package/dist/gateway-server.js +1 -0
- package/dist/gemini-json-parser.d.ts +21 -0
- package/dist/gemini-json-parser.js +47 -0
- package/dist/health.d.ts +7 -0
- package/dist/health.js +22 -0
- package/dist/http-transport.d.ts +22 -0
- package/dist/http-transport.js +164 -0
- package/dist/index.d.ts +186 -2
- package/dist/index.js +2761 -1454
- package/dist/job-store.d.ts +118 -2
- package/dist/job-store.js +176 -5
- package/dist/logger.d.ts +9 -0
- package/dist/logger.js +14 -0
- package/dist/model-registry.js +40 -6
- package/dist/provider-login-guidance.d.ts +21 -0
- package/dist/provider-login-guidance.js +98 -0
- package/dist/provider-status.d.ts +41 -0
- package/dist/provider-status.js +203 -0
- package/dist/request-helpers.d.ts +484 -4
- package/dist/request-helpers.js +613 -0
- package/dist/resources.js +44 -0
- package/dist/session-manager-pg.js +1 -0
- package/dist/session-manager.d.ts +1 -1
- package/dist/session-manager.js +2 -1
- package/dist/upstream-contracts.d.ts +62 -0
- package/dist/upstream-contracts.js +620 -0
- package/dist/validation-normalizer.d.ts +23 -0
- package/dist/validation-normalizer.js +79 -0
- package/dist/validation-orchestrator.d.ts +47 -0
- package/dist/validation-orchestrator.js +145 -0
- package/dist/validation-prompts.d.ts +15 -0
- package/dist/validation-prompts.js +52 -0
- package/dist/validation-report.d.ts +57 -0
- package/dist/validation-report.js +129 -0
- package/dist/validation-tools.d.ts +7 -0
- package/dist/validation-tools.js +198 -0
- package/package.json +25 -10
- package/setup/status.schema.json +271 -0
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir, platform, arch, release } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { loadAuthConfig } from "./auth.js";
|
|
6
|
+
import { createEndpointExposureReport, redactDiagnosticUrl, } from "./endpoint-exposure.js";
|
|
7
|
+
import { listProviderRuntimeStatuses, } from "./provider-status.js";
|
|
8
|
+
import { CLAUDE_MCP_SERVER_NAMES } from "./claude-mcp-config.js";
|
|
9
|
+
/**
|
|
10
|
+
* Probe ~/.vibe/config.toml to see whether session_logging is enabled. Vibe
|
|
11
|
+
* persists session logs (which sessionId/--continue depends on) only when
|
|
12
|
+
* `[session_logging] enabled = true` is set. The probe is read-only: the
|
|
13
|
+
* gateway never mutates this file.
|
|
14
|
+
*/
|
|
15
|
+
export function checkVibeSessionLogging(home = homedir()) {
|
|
16
|
+
const configPath = join(home, ".vibe", "config.toml");
|
|
17
|
+
if (!existsSync(configPath)) {
|
|
18
|
+
return {
|
|
19
|
+
config_path: configPath,
|
|
20
|
+
config_present: false,
|
|
21
|
+
session_logging_enabled: false,
|
|
22
|
+
note: "~/.vibe/config.toml not found. Run `vibe config set session_logging.enabled true` or create the file with a [session_logging]\\nenabled = true block.",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const text = readFileSync(configPath, "utf8");
|
|
27
|
+
const enabled = parseVibeSessionLoggingEnabled(text);
|
|
28
|
+
if (enabled) {
|
|
29
|
+
return {
|
|
30
|
+
config_path: configPath,
|
|
31
|
+
config_present: true,
|
|
32
|
+
session_logging_enabled: true,
|
|
33
|
+
note: "session_logging.enabled is true; --continue/--resume will work for mistral_request.",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
config_path: configPath,
|
|
38
|
+
config_present: true,
|
|
39
|
+
session_logging_enabled: false,
|
|
40
|
+
note: "[session_logging] enabled = false (or missing). Run `vibe config set session_logging.enabled true` or edit ~/.vibe/config.toml so mistral_request --resume / --continue can persist sessions.",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
45
|
+
return {
|
|
46
|
+
config_path: configPath,
|
|
47
|
+
config_present: true,
|
|
48
|
+
session_logging_enabled: false,
|
|
49
|
+
note: `Could not parse ~/.vibe/config.toml: ${message}. Verify the file is valid TOML.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Tiny TOML probe focused on `[session_logging] enabled = true`. Avoids pulling
|
|
55
|
+
* in the full `toml` parser when only one boolean is needed.
|
|
56
|
+
*/
|
|
57
|
+
function parseVibeSessionLoggingEnabled(text) {
|
|
58
|
+
const lines = text.split(/\r?\n/);
|
|
59
|
+
let inSection = false;
|
|
60
|
+
for (let raw of lines) {
|
|
61
|
+
const line = raw.replace(/#.*$/, "").trim();
|
|
62
|
+
if (!line)
|
|
63
|
+
continue;
|
|
64
|
+
const sectionMatch = line.match(/^\[\s*([A-Za-z0-9_.-]+)\s*\]$/);
|
|
65
|
+
if (sectionMatch) {
|
|
66
|
+
inSection = sectionMatch[1] === "session_logging";
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (inSection) {
|
|
70
|
+
const kv = line.match(/^enabled\s*=\s*(.+)$/);
|
|
71
|
+
if (kv) {
|
|
72
|
+
const value = kv[1].trim().toLowerCase();
|
|
73
|
+
return value === "true";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
// Allow dotted form: session_logging.enabled = true
|
|
78
|
+
const dotted = line.match(/^session_logging\.enabled\s*=\s*(.+)$/);
|
|
79
|
+
if (dotted) {
|
|
80
|
+
const value = dotted[1].trim().toLowerCase();
|
|
81
|
+
return value === "true";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* U27: Probe Gemini's project/user config locations.
|
|
89
|
+
*
|
|
90
|
+
* - `./GEMINI.md` (gateway cwd) and `~/.gemini/GEMINI.md` are documented
|
|
91
|
+
* "context" surfaces. Missing both means Gemini has no project-specific
|
|
92
|
+
* guidance.
|
|
93
|
+
* - `~/.gemini/settings.json` defines registered MCP servers (`mcpServers`
|
|
94
|
+
* block). The gateway tracks its own whitelist (`CLAUDE_MCP_SERVER_NAMES`)
|
|
95
|
+
* and surfaces a reconciliation warning for each whitelisted server not
|
|
96
|
+
* present in settings.json so callers don't ship requests for unregistered
|
|
97
|
+
* servers.
|
|
98
|
+
*/
|
|
99
|
+
export function checkGeminiConfig(cwd = process.cwd(), home = homedir(), whitelist = CLAUDE_MCP_SERVER_NAMES) {
|
|
100
|
+
const projectGeminiMd = join(cwd, "GEMINI.md");
|
|
101
|
+
const userGeminiMd = join(home, ".gemini", "GEMINI.md");
|
|
102
|
+
const settingsPath = join(home, ".gemini", "settings.json");
|
|
103
|
+
const projectGeminiMdPresent = existsSync(projectGeminiMd);
|
|
104
|
+
const userGeminiMdPresent = existsSync(userGeminiMd);
|
|
105
|
+
const settingsPresent = existsSync(settingsPath);
|
|
106
|
+
let mcpServersRegistered = [];
|
|
107
|
+
if (settingsPresent) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = readFileSync(settingsPath, "utf8");
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
if (parsed && typeof parsed.mcpServers === "object" && parsed.mcpServers) {
|
|
112
|
+
mcpServersRegistered = Object.keys(parsed.mcpServers);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Best-effort: leave list empty so the next_action surfaces the gap.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const missingFromSettings = whitelist.filter(name => !mcpServersRegistered.includes(name));
|
|
120
|
+
const nextActions = [];
|
|
121
|
+
if (!projectGeminiMdPresent && !userGeminiMdPresent) {
|
|
122
|
+
nextActions.push(`Create ${projectGeminiMd} to give Gemini project-specific context (or ${userGeminiMd} for a user-wide default).`);
|
|
123
|
+
}
|
|
124
|
+
if (!settingsPresent) {
|
|
125
|
+
nextActions.push(`Create ${settingsPath} to register MCP servers (mcpServers block). Run \`gemini mcp add <name>\` for each gateway-whitelisted server.`);
|
|
126
|
+
}
|
|
127
|
+
for (const name of missingFromSettings) {
|
|
128
|
+
nextActions.push(`MCP server \`${name}\` is whitelisted by the gateway but not registered in ${settingsPath}. Run \`gemini mcp add ${name}\` to register it.`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
project_gemini_md_present: projectGeminiMdPresent,
|
|
132
|
+
project_gemini_md_path: projectGeminiMd,
|
|
133
|
+
user_gemini_md_present: userGeminiMdPresent,
|
|
134
|
+
user_gemini_md_path: userGeminiMd,
|
|
135
|
+
settings_json_present: settingsPresent,
|
|
136
|
+
settings_json_path: settingsPath,
|
|
137
|
+
mcp_servers_registered: mcpServersRegistered,
|
|
138
|
+
mcp_reconciliation: {
|
|
139
|
+
whitelisted: [...whitelist],
|
|
140
|
+
missing_from_settings: missingFromSettings,
|
|
141
|
+
},
|
|
142
|
+
next_actions: nextActions,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function packageVersion() {
|
|
146
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
147
|
+
const candidates = [join(here, "..", "package.json"), join(here, "..", "..", "package.json")];
|
|
148
|
+
for (const candidate of candidates) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
|
|
151
|
+
return parsed.version || "unknown";
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Try next candidate.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return "unknown";
|
|
158
|
+
}
|
|
159
|
+
function clientConfigStatus(home = homedir()) {
|
|
160
|
+
return {
|
|
161
|
+
claude_desktop_config_present: existsSync(join(home, "Library/Application Support/Claude/claude_desktop_config.json")) ||
|
|
162
|
+
existsSync(join(home, ".config", "Claude", "claude_desktop_config.json")),
|
|
163
|
+
codex_config_present: existsSync(join(home, ".codex", "config.toml")),
|
|
164
|
+
gemini_settings_present: existsSync(join(home, ".gemini", "settings.json")) ||
|
|
165
|
+
existsSync(join(home, ".config", "gemini", "settings.json")),
|
|
166
|
+
gemini_config: checkGeminiConfig(process.cwd(), home),
|
|
167
|
+
vibe_session_logging: checkVibeSessionLogging(home),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function defaultTransport(env) {
|
|
171
|
+
if (env.LLM_GATEWAY_TRANSPORT === "http" || env.MCP_TRANSPORT === "http")
|
|
172
|
+
return "http";
|
|
173
|
+
return "stdio";
|
|
174
|
+
}
|
|
175
|
+
export function createDoctorReport(env = process.env) {
|
|
176
|
+
const auth = loadAuthConfig(env);
|
|
177
|
+
const transport = defaultTransport(env);
|
|
178
|
+
const rawPublicUrl = env.LLM_GATEWAY_PUBLIC_URL || null;
|
|
179
|
+
const publicUrl = redactDiagnosticUrl(rawPublicUrl);
|
|
180
|
+
const endpointExposure = createEndpointExposureReport(env, publicUrl);
|
|
181
|
+
const providerStatuses = listProviderRuntimeStatuses();
|
|
182
|
+
const report = {
|
|
183
|
+
schema_version: "1.0",
|
|
184
|
+
ok: true,
|
|
185
|
+
generated_at: new Date().toISOString(),
|
|
186
|
+
system: {
|
|
187
|
+
os: platform(),
|
|
188
|
+
arch: arch(),
|
|
189
|
+
release: release(),
|
|
190
|
+
node_version: process.version,
|
|
191
|
+
},
|
|
192
|
+
gateway: {
|
|
193
|
+
name: "llm-cli-gateway",
|
|
194
|
+
version: packageVersion(),
|
|
195
|
+
},
|
|
196
|
+
transport: {
|
|
197
|
+
default: transport,
|
|
198
|
+
http: {
|
|
199
|
+
enabled: transport === "http",
|
|
200
|
+
host: env.LLM_GATEWAY_HTTP_HOST || "127.0.0.1",
|
|
201
|
+
port: Number(env.LLM_GATEWAY_HTTP_PORT || 3333),
|
|
202
|
+
path: env.LLM_GATEWAY_HTTP_PATH || "/mcp",
|
|
203
|
+
public_url_configured: Boolean(publicUrl),
|
|
204
|
+
public_url: publicUrl,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
auth: {
|
|
208
|
+
required: auth.required,
|
|
209
|
+
token_configured: auth.tokenConfigured,
|
|
210
|
+
source: auth.source,
|
|
211
|
+
},
|
|
212
|
+
providers: {
|
|
213
|
+
claude: doctorProviderStatus(providerStatuses.claude),
|
|
214
|
+
codex: doctorProviderStatus(providerStatuses.codex),
|
|
215
|
+
gemini: doctorProviderStatus(providerStatuses.gemini),
|
|
216
|
+
grok: doctorProviderStatus(providerStatuses.grok),
|
|
217
|
+
mistral: doctorProviderStatus(providerStatuses.mistral),
|
|
218
|
+
},
|
|
219
|
+
endpoint_exposure: endpointExposure,
|
|
220
|
+
client_config: clientConfigStatus(),
|
|
221
|
+
next_actions: [],
|
|
222
|
+
};
|
|
223
|
+
if (transport === "http" && auth.required && !auth.tokenConfigured) {
|
|
224
|
+
report.ok = false;
|
|
225
|
+
report.next_actions.push("Set LLM_GATEWAY_AUTH_TOKEN before starting HTTP transport.");
|
|
226
|
+
}
|
|
227
|
+
report.next_actions.push(...endpointExposure.next_actions);
|
|
228
|
+
for (const [name, provider] of Object.entries(report.providers)) {
|
|
229
|
+
if (!provider.cli_available) {
|
|
230
|
+
report.next_actions.push(provider.install_guidance.summary);
|
|
231
|
+
}
|
|
232
|
+
else if (provider.login_status !== "authenticated") {
|
|
233
|
+
report.next_actions.push(`${name}: ${provider.login_guidance.summary}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Mistral-specific: surface the session_logging toggle BEFORE a --continue/--resume
|
|
237
|
+
// request fails opaquely. The check is read-only; the gateway never mutates the file.
|
|
238
|
+
const vibeStatus = report.client_config.vibe_session_logging;
|
|
239
|
+
if (report.providers.mistral.cli_available && !vibeStatus.session_logging_enabled) {
|
|
240
|
+
report.next_actions.push(`mistral: ${vibeStatus.note}`);
|
|
241
|
+
}
|
|
242
|
+
// U27: surface Gemini config gaps (missing GEMINI.md, missing settings.json,
|
|
243
|
+
// MCP-server whitelist drift) only when Gemini CLI is actually installed.
|
|
244
|
+
if (report.providers.gemini.cli_available) {
|
|
245
|
+
for (const action of report.client_config.gemini_config.next_actions) {
|
|
246
|
+
report.next_actions.push(`gemini: ${action}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (report.next_actions.length === 0) {
|
|
250
|
+
report.next_actions.push("Run a client setup guide and verify with doctor --json after each step.");
|
|
251
|
+
}
|
|
252
|
+
return report;
|
|
253
|
+
}
|
|
254
|
+
export function printDoctorJson() {
|
|
255
|
+
process.stdout.write(`${JSON.stringify(createDoctorReport(), null, 2)}\n`);
|
|
256
|
+
}
|
|
257
|
+
function doctorProviderStatus(provider) {
|
|
258
|
+
return {
|
|
259
|
+
cli_available: provider.installed,
|
|
260
|
+
version: provider.version,
|
|
261
|
+
login_status: provider.loginStatus,
|
|
262
|
+
version_command: provider.versionCommand,
|
|
263
|
+
login_check: {
|
|
264
|
+
method: provider.loginCheck.method,
|
|
265
|
+
command: provider.loginCheck.command,
|
|
266
|
+
credential_store: provider.loginCheck.credentialStore,
|
|
267
|
+
detail: provider.loginCheck.detail,
|
|
268
|
+
},
|
|
269
|
+
install_guidance: {
|
|
270
|
+
summary: provider.guidance.install.summary,
|
|
271
|
+
commands: provider.guidance.install.commands,
|
|
272
|
+
documentation_url: provider.guidance.install.documentationUrl,
|
|
273
|
+
},
|
|
274
|
+
login_guidance: {
|
|
275
|
+
summary: provider.guidance.login.summary,
|
|
276
|
+
commands: provider.guidance.login.commands,
|
|
277
|
+
credential_handling: provider.guidance.login.credentialHandling,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type EndpointExposureMode = "local_only" | "lan" | "tunnel" | "byo_reverse_proxy" | "misconfigured";
|
|
2
|
+
export type EndpointReachability = "not_checked" | "reachable" | "unreachable";
|
|
3
|
+
export interface EndpointExposureReport {
|
|
4
|
+
mode: EndpointExposureMode;
|
|
5
|
+
local_url: string;
|
|
6
|
+
public_url_configured: boolean;
|
|
7
|
+
public_url: string | null;
|
|
8
|
+
https_required_for_web: boolean;
|
|
9
|
+
https_configured: boolean;
|
|
10
|
+
web_clients_supported: boolean;
|
|
11
|
+
tunnel_provider: string | null;
|
|
12
|
+
reachable_from_web: EndpointReachability;
|
|
13
|
+
verification: {
|
|
14
|
+
method: "not_checked" | "http_head";
|
|
15
|
+
checked_url: string | null;
|
|
16
|
+
status_code: number | null;
|
|
17
|
+
error: string | null;
|
|
18
|
+
};
|
|
19
|
+
next_actions: string[];
|
|
20
|
+
}
|
|
21
|
+
export declare function createEndpointExposureReport(env: NodeJS.ProcessEnv, redactedPublicUrl: string | null): EndpointExposureReport;
|
|
22
|
+
export declare function redactDiagnosticUrl(rawUrl: string | null): string | null;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
const TUNNEL_HOST_PATTERNS = [
|
|
3
|
+
/cloudflare/i,
|
|
4
|
+
/trycloudflare\.com$/i,
|
|
5
|
+
/ngrok(?:-free)?\.app$/i,
|
|
6
|
+
/ngrok\.io$/i,
|
|
7
|
+
/ts\.net$/i,
|
|
8
|
+
/tailscale/i,
|
|
9
|
+
];
|
|
10
|
+
export function createEndpointExposureReport(env, redactedPublicUrl) {
|
|
11
|
+
const host = env.LLM_GATEWAY_HTTP_HOST || "127.0.0.1";
|
|
12
|
+
const port = Number(env.LLM_GATEWAY_HTTP_PORT || 3333);
|
|
13
|
+
const path = env.LLM_GATEWAY_HTTP_PATH || "/mcp";
|
|
14
|
+
const localHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
15
|
+
const localUrl = `http://${localHost}:${port}${path}`;
|
|
16
|
+
const rawPublicUrl = env.LLM_GATEWAY_PUBLIC_URL || "";
|
|
17
|
+
const tunnelProvider = env.LLM_GATEWAY_TUNNEL_PROVIDER || inferTunnelProvider(rawPublicUrl);
|
|
18
|
+
const httpsConfigured = rawPublicUrl.startsWith("https://");
|
|
19
|
+
const publicConfigured = Boolean(redactedPublicUrl);
|
|
20
|
+
const mode = classifyEndpointMode({ host, rawPublicUrl, tunnelProvider, httpsConfigured });
|
|
21
|
+
const verification = maybeVerifyEndpoint(env, rawPublicUrl, mode);
|
|
22
|
+
const webClientsSupported = publicConfigured &&
|
|
23
|
+
httpsConfigured &&
|
|
24
|
+
mode !== "local_only" &&
|
|
25
|
+
mode !== "lan" &&
|
|
26
|
+
verification.reachable_from_web === "reachable";
|
|
27
|
+
const nextActions = [];
|
|
28
|
+
if (!publicConfigured) {
|
|
29
|
+
nextActions.push("Keep using local stdio/CLI clients, or configure an HTTPS tunnel before web-client setup.");
|
|
30
|
+
}
|
|
31
|
+
else if (mode === "local_only" || mode === "lan") {
|
|
32
|
+
nextActions.push("Set LLM_GATEWAY_PUBLIC_URL to a public HTTPS tunnel or reverse-proxy URL, not localhost or a LAN address.");
|
|
33
|
+
}
|
|
34
|
+
else if (!httpsConfigured) {
|
|
35
|
+
nextActions.push("Use an HTTPS public URL before configuring ChatGPT, Claude web, or Grok web connectors.");
|
|
36
|
+
}
|
|
37
|
+
if (publicConfigured && mode !== "local_only" && mode !== "lan") {
|
|
38
|
+
if (verification.method === "not_checked") {
|
|
39
|
+
nextActions.push("Set LLM_GATEWAY_VERIFY_PUBLIC_URL=1 to have doctor check public endpoint reachability.");
|
|
40
|
+
}
|
|
41
|
+
else if (verification.reachable_from_web === "unreachable") {
|
|
42
|
+
nextActions.push("Fix tunnel/proxy routing until the public MCP URL is reachable, then rerun doctor --json.");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
mode,
|
|
47
|
+
local_url: localUrl,
|
|
48
|
+
public_url_configured: publicConfigured,
|
|
49
|
+
public_url: redactedPublicUrl,
|
|
50
|
+
https_required_for_web: true,
|
|
51
|
+
https_configured: httpsConfigured,
|
|
52
|
+
web_clients_supported: webClientsSupported,
|
|
53
|
+
tunnel_provider: tunnelProvider || null,
|
|
54
|
+
reachable_from_web: verification.reachable_from_web,
|
|
55
|
+
verification: {
|
|
56
|
+
method: verification.method,
|
|
57
|
+
checked_url: verification.checked_url ? redactDiagnosticUrl(verification.checked_url) : null,
|
|
58
|
+
status_code: verification.status_code,
|
|
59
|
+
error: verification.error,
|
|
60
|
+
},
|
|
61
|
+
next_actions: nextActions,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function redactDiagnosticUrl(rawUrl) {
|
|
65
|
+
if (!rawUrl)
|
|
66
|
+
return null;
|
|
67
|
+
const sensitiveKeyPattern = /auth|bearer|token|secret|credential|password|authorization|signature|api[_-]?key|access[_-]?key|jwt|cookie|session/i;
|
|
68
|
+
const redactSensitivePairs = (value) => value.replace(new RegExp(`((${sensitiveKeyPattern.source})=)[^&\\s#]+`, "gi"), "$1<redacted>");
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(rawUrl);
|
|
71
|
+
if (url.username)
|
|
72
|
+
url.username = "<redacted>";
|
|
73
|
+
if (url.password)
|
|
74
|
+
url.password = "<redacted>";
|
|
75
|
+
for (const key of Array.from(url.searchParams.keys())) {
|
|
76
|
+
if (sensitiveKeyPattern.test(key)) {
|
|
77
|
+
url.searchParams.set(key, "<redacted>");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
url.hash = redactSensitivePairs(url.hash);
|
|
81
|
+
return url.toString().replace(/%3Credacted%3E/gi, "<redacted>");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return redactSensitivePairs(rawUrl.replace(/(https?:\/\/)[^/@]+@/gi, "$1<redacted>@"));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function classifyEndpointMode(input) {
|
|
88
|
+
if (!input.rawPublicUrl) {
|
|
89
|
+
if (input.host === "0.0.0.0" || input.host === "::" || isLanHost(input.host))
|
|
90
|
+
return "lan";
|
|
91
|
+
return "local_only";
|
|
92
|
+
}
|
|
93
|
+
const publicHost = publicUrlHost(input.rawPublicUrl);
|
|
94
|
+
if (!publicHost)
|
|
95
|
+
return "misconfigured";
|
|
96
|
+
if (isLoopbackHost(publicHost))
|
|
97
|
+
return "local_only";
|
|
98
|
+
if (isLanHost(publicHost))
|
|
99
|
+
return "lan";
|
|
100
|
+
if (!input.httpsConfigured)
|
|
101
|
+
return "misconfigured";
|
|
102
|
+
if (input.tunnelProvider)
|
|
103
|
+
return "tunnel";
|
|
104
|
+
return "byo_reverse_proxy";
|
|
105
|
+
}
|
|
106
|
+
function inferTunnelProvider(rawUrl) {
|
|
107
|
+
if (!rawUrl)
|
|
108
|
+
return "";
|
|
109
|
+
try {
|
|
110
|
+
const host = new URL(rawUrl).hostname;
|
|
111
|
+
if (TUNNEL_HOST_PATTERNS.some(pattern => pattern.test(host)))
|
|
112
|
+
return host;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Treat unparsable URLs as not classified.
|
|
116
|
+
}
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
function isLanHost(host) {
|
|
120
|
+
const normalized = normalizeHost(host);
|
|
121
|
+
const mappedIpv4 = ipv4FromMappedIpv6(normalized);
|
|
122
|
+
if (mappedIpv4)
|
|
123
|
+
return isLanHost(mappedIpv4);
|
|
124
|
+
return (/^10\./.test(normalized) ||
|
|
125
|
+
/^192\.168\./.test(normalized) ||
|
|
126
|
+
/^172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) ||
|
|
127
|
+
/^f[cd][0-9a-f]{2}:/i.test(normalized) ||
|
|
128
|
+
/^fe80:/i.test(normalized));
|
|
129
|
+
}
|
|
130
|
+
function isLoopbackHost(host) {
|
|
131
|
+
const normalized = normalizeHost(host);
|
|
132
|
+
const mappedIpv4 = ipv4FromMappedIpv6(normalized);
|
|
133
|
+
if (mappedIpv4)
|
|
134
|
+
return isLoopbackHost(mappedIpv4);
|
|
135
|
+
return (normalized === "localhost" ||
|
|
136
|
+
normalized === "0.0.0.0" ||
|
|
137
|
+
normalized === "::" ||
|
|
138
|
+
normalized === "::1" ||
|
|
139
|
+
/^127\./.test(normalized));
|
|
140
|
+
}
|
|
141
|
+
function publicUrlHost(rawUrl) {
|
|
142
|
+
try {
|
|
143
|
+
return normalizeHost(new URL(rawUrl).hostname);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function normalizeHost(host) {
|
|
150
|
+
return host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
151
|
+
}
|
|
152
|
+
function ipv4FromMappedIpv6(host) {
|
|
153
|
+
const dotted = host.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
|
|
154
|
+
if (dotted)
|
|
155
|
+
return dotted[1];
|
|
156
|
+
const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
|
157
|
+
if (!hex)
|
|
158
|
+
return null;
|
|
159
|
+
const high = Number.parseInt(hex[1], 16);
|
|
160
|
+
const low = Number.parseInt(hex[2], 16);
|
|
161
|
+
if (!Number.isFinite(high) || !Number.isFinite(low))
|
|
162
|
+
return null;
|
|
163
|
+
return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`;
|
|
164
|
+
}
|
|
165
|
+
function maybeVerifyEndpoint(env, rawPublicUrl, mode) {
|
|
166
|
+
if (!rawPublicUrl || env.LLM_GATEWAY_VERIFY_PUBLIC_URL !== "1") {
|
|
167
|
+
return {
|
|
168
|
+
method: "not_checked",
|
|
169
|
+
checked_url: rawPublicUrl || null,
|
|
170
|
+
status_code: null,
|
|
171
|
+
error: null,
|
|
172
|
+
reachable_from_web: "not_checked",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (mode === "local_only" || mode === "lan") {
|
|
176
|
+
return {
|
|
177
|
+
method: "not_checked",
|
|
178
|
+
checked_url: rawPublicUrl,
|
|
179
|
+
status_code: null,
|
|
180
|
+
error: "Public URL points to localhost or a private LAN address.",
|
|
181
|
+
reachable_from_web: "unreachable",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const result = verifyEndpointSync(rawPublicUrl, 3_000);
|
|
185
|
+
return {
|
|
186
|
+
method: "http_head",
|
|
187
|
+
checked_url: rawPublicUrl,
|
|
188
|
+
status_code: result.statusCode,
|
|
189
|
+
error: result.error,
|
|
190
|
+
reachable_from_web: result.ok ? "reachable" : "unreachable",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function verifyEndpointSync(url, timeoutMs) {
|
|
194
|
+
const script = `
|
|
195
|
+
const { request: http } = require("node:http");
|
|
196
|
+
const { request: https } = require("node:https");
|
|
197
|
+
const target = new URL(process.argv[1]);
|
|
198
|
+
const timeout = Number(process.argv[2]);
|
|
199
|
+
const requester = target.protocol === "https:" ? https : http;
|
|
200
|
+
const req = requester(target, { method: "HEAD", timeout, headers: { accept: "application/json, text/event-stream" } }, res => {
|
|
201
|
+
res.resume();
|
|
202
|
+
const statusCode = res.statusCode || 0;
|
|
203
|
+
const endpointFound = statusCode < 500 && statusCode !== 404;
|
|
204
|
+
console.log(JSON.stringify({ ok: endpointFound, statusCode: res.statusCode, error: null }));
|
|
205
|
+
});
|
|
206
|
+
req.on("timeout", () => {
|
|
207
|
+
req.destroy(new Error("Endpoint verification timed out."));
|
|
208
|
+
});
|
|
209
|
+
req.on("error", err => {
|
|
210
|
+
console.log(JSON.stringify({ ok: false, statusCode: null, error: err.message }));
|
|
211
|
+
});
|
|
212
|
+
req.end();
|
|
213
|
+
`;
|
|
214
|
+
const result = spawnSync(process.execPath, ["-e", script, url, String(timeoutMs)], {
|
|
215
|
+
encoding: "utf8",
|
|
216
|
+
timeout: timeoutMs + 1_000,
|
|
217
|
+
});
|
|
218
|
+
if (result.error) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
statusCode: null,
|
|
222
|
+
error: result.error.message,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(result.stdout.trim());
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return { ok: false, statusCode: null, error: "Endpoint verification failed." };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function entrypointFileURL(path: string | undefined): string;
|
package/dist/executor.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { ChildProcess } from "child_process";
|
|
1
|
+
import { ChildProcess, type SpawnOptions } from "child_process";
|
|
2
2
|
import type { Logger } from "./logger.js";
|
|
3
3
|
export interface ExecuteOptions {
|
|
4
4
|
timeout?: number;
|
|
5
5
|
idleTimeout?: number;
|
|
6
6
|
cwd?: string;
|
|
7
7
|
logger?: Logger;
|
|
8
|
+
/** Extra environment variables to inject; merged after PATH. */
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
8
10
|
}
|
|
9
11
|
export interface ExecuteResult {
|
|
10
12
|
stdout: string;
|
|
@@ -12,6 +14,7 @@ export interface ExecuteResult {
|
|
|
12
14
|
code: number;
|
|
13
15
|
}
|
|
14
16
|
export declare function getExtendedPath(): string;
|
|
17
|
+
export declare function shouldDetachProviderProcess(platform?: NodeJS.Platform): boolean;
|
|
15
18
|
export declare function registerProcessGroup(pid: number): void;
|
|
16
19
|
export declare function unregisterProcessGroup(pid: number): void;
|
|
17
20
|
/**
|
|
@@ -27,4 +30,9 @@ export declare function killAllProcessGroups(): Promise<void>;
|
|
|
27
30
|
* if the group kill fails (e.g., pid not yet assigned).
|
|
28
31
|
*/
|
|
29
32
|
export declare function killProcessGroup(proc: ChildProcess, signal: NodeJS.Signals): boolean;
|
|
33
|
+
export declare function spawnCliProcess(command: string, args: string[], options: {
|
|
34
|
+
cwd?: string;
|
|
35
|
+
env: NodeJS.ProcessEnv;
|
|
36
|
+
stdio: SpawnOptions["stdio"];
|
|
37
|
+
}): ChildProcess;
|
|
30
38
|
export declare function executeCli(command: string, args: string[], options?: ExecuteOptions): Promise<ExecuteResult>;
|
package/dist/executor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
1
|
+
import { spawn, spawnSync } from "child_process";
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join, dirname } from "path";
|
|
4
4
|
import { readdirSync, existsSync } from "fs";
|
|
@@ -55,6 +55,12 @@ export function getExtendedPath() {
|
|
|
55
55
|
}
|
|
56
56
|
/** Registry of active detached process groups for shutdown cleanup. */
|
|
57
57
|
const activeProcessGroups = new Set();
|
|
58
|
+
export function shouldDetachProviderProcess(platform = process.platform) {
|
|
59
|
+
// On Windows, detached console children can flash visible cmd/conhost windows
|
|
60
|
+
// when provider CLIs are native console apps or .cmd shims. Keep them in the
|
|
61
|
+
// gateway process tree and rely on hidden-window spawn plus taskkill cleanup.
|
|
62
|
+
return platform !== "win32";
|
|
63
|
+
}
|
|
58
64
|
export function registerProcessGroup(pid) {
|
|
59
65
|
activeProcessGroups.add(pid);
|
|
60
66
|
}
|
|
@@ -72,21 +78,31 @@ export function killAllProcessGroups() {
|
|
|
72
78
|
if (activeProcessGroups.size === 0)
|
|
73
79
|
return Promise.resolve();
|
|
74
80
|
for (const pid of activeProcessGroups) {
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
if (process.platform === "win32") {
|
|
82
|
+
killWindowsProcessTree(pid);
|
|
77
83
|
}
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
else {
|
|
85
|
+
try {
|
|
86
|
+
process.kill(-pid, "SIGTERM");
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* ESRCH ok */
|
|
90
|
+
}
|
|
80
91
|
}
|
|
81
92
|
}
|
|
82
93
|
return new Promise(resolve => {
|
|
83
94
|
setTimeout(() => {
|
|
84
95
|
for (const pid of activeProcessGroups) {
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
if (process.platform === "win32") {
|
|
97
|
+
killWindowsProcessTree(pid);
|
|
87
98
|
}
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
else {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(-pid, "SIGKILL");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* ESRCH ok */
|
|
105
|
+
}
|
|
90
106
|
}
|
|
91
107
|
}
|
|
92
108
|
activeProcessGroups.clear();
|
|
@@ -100,6 +116,9 @@ export function killAllProcessGroups() {
|
|
|
100
116
|
*/
|
|
101
117
|
export function killProcessGroup(proc, signal) {
|
|
102
118
|
if (proc.pid) {
|
|
119
|
+
if (process.platform === "win32") {
|
|
120
|
+
return killWindowsProcessTree(proc.pid);
|
|
121
|
+
}
|
|
103
122
|
try {
|
|
104
123
|
process.kill(-proc.pid, signal);
|
|
105
124
|
return true;
|
|
@@ -124,21 +143,37 @@ export function killProcessGroup(proc, signal) {
|
|
|
124
143
|
return false;
|
|
125
144
|
}
|
|
126
145
|
}
|
|
146
|
+
function killWindowsProcessTree(pid) {
|
|
147
|
+
const result = spawnSync("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
|
|
148
|
+
stdio: "ignore",
|
|
149
|
+
windowsHide: true,
|
|
150
|
+
});
|
|
151
|
+
return result.status === 0;
|
|
152
|
+
}
|
|
153
|
+
export function spawnCliProcess(command, args, options) {
|
|
154
|
+
const detached = shouldDetachProviderProcess();
|
|
155
|
+
const proc = spawn(command, args, {
|
|
156
|
+
cwd: options.cwd,
|
|
157
|
+
detached,
|
|
158
|
+
windowsHide: true,
|
|
159
|
+
stdio: options.stdio,
|
|
160
|
+
env: options.env,
|
|
161
|
+
});
|
|
162
|
+
if (proc.pid)
|
|
163
|
+
registerProcessGroup(proc.pid);
|
|
164
|
+
proc.unref();
|
|
165
|
+
return proc;
|
|
166
|
+
}
|
|
127
167
|
export async function executeCli(command, args, options = {}) {
|
|
128
|
-
const { timeout, idleTimeout, cwd } = options;
|
|
168
|
+
const { timeout, idleTimeout, cwd, env: extraEnv } = options;
|
|
129
169
|
const extendedPath = getExtendedPath();
|
|
130
170
|
const circuitBreaker = getCircuitBreaker(command);
|
|
131
171
|
const runOnce = () => new Promise((resolve, reject) => {
|
|
132
|
-
const proc =
|
|
172
|
+
const proc = spawnCliProcess(command, args, {
|
|
133
173
|
cwd,
|
|
134
|
-
detached: true,
|
|
135
174
|
stdio: ["ignore", "pipe", "pipe"],
|
|
136
|
-
env: { ...process.env, PATH: extendedPath },
|
|
175
|
+
env: { ...process.env, PATH: extendedPath, ...(extraEnv ?? {}) },
|
|
137
176
|
});
|
|
138
|
-
if (proc.pid)
|
|
139
|
-
registerProcessGroup(proc.pid);
|
|
140
|
-
// Prevent detached process from keeping parent alive when not needed
|
|
141
|
-
proc.unref();
|
|
142
177
|
let stdout = "";
|
|
143
178
|
let stderr = "";
|
|
144
179
|
let timedOut = false;
|