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.
Files changed (62) hide show
  1. package/CHANGELOG.md +135 -1
  2. package/README.md +358 -15
  3. package/dist/approval-manager.d.ts +1 -1
  4. package/dist/async-job-manager.d.ts +32 -2
  5. package/dist/async-job-manager.js +101 -16
  6. package/dist/auth.d.ts +15 -0
  7. package/dist/auth.js +46 -0
  8. package/dist/cli-updater.d.ts +19 -2
  9. package/dist/cli-updater.js +110 -7
  10. package/dist/codex-json-parser.d.ts +34 -0
  11. package/dist/codex-json-parser.js +105 -0
  12. package/dist/config.d.ts +30 -0
  13. package/dist/config.js +167 -0
  14. package/dist/doctor.d.ts +110 -0
  15. package/dist/doctor.js +280 -0
  16. package/dist/endpoint-exposure.d.ts +22 -0
  17. package/dist/endpoint-exposure.js +231 -0
  18. package/dist/entrypoint-url.d.ts +1 -0
  19. package/dist/entrypoint-url.js +5 -0
  20. package/dist/executor.d.ts +9 -1
  21. package/dist/executor.js +52 -17
  22. package/dist/flight-recorder.d.ts +3 -1
  23. package/dist/flight-recorder.js +31 -2
  24. package/dist/gateway-server.d.ts +2 -0
  25. package/dist/gateway-server.js +1 -0
  26. package/dist/gemini-json-parser.d.ts +21 -0
  27. package/dist/gemini-json-parser.js +47 -0
  28. package/dist/health.d.ts +7 -0
  29. package/dist/health.js +22 -0
  30. package/dist/http-transport.d.ts +22 -0
  31. package/dist/http-transport.js +164 -0
  32. package/dist/index.d.ts +186 -2
  33. package/dist/index.js +2761 -1454
  34. package/dist/job-store.d.ts +118 -2
  35. package/dist/job-store.js +176 -5
  36. package/dist/logger.d.ts +9 -0
  37. package/dist/logger.js +14 -0
  38. package/dist/model-registry.js +40 -6
  39. package/dist/provider-login-guidance.d.ts +21 -0
  40. package/dist/provider-login-guidance.js +98 -0
  41. package/dist/provider-status.d.ts +41 -0
  42. package/dist/provider-status.js +203 -0
  43. package/dist/request-helpers.d.ts +484 -4
  44. package/dist/request-helpers.js +613 -0
  45. package/dist/resources.js +44 -0
  46. package/dist/session-manager-pg.js +1 -0
  47. package/dist/session-manager.d.ts +1 -1
  48. package/dist/session-manager.js +2 -1
  49. package/dist/upstream-contracts.d.ts +62 -0
  50. package/dist/upstream-contracts.js +620 -0
  51. package/dist/validation-normalizer.d.ts +23 -0
  52. package/dist/validation-normalizer.js +79 -0
  53. package/dist/validation-orchestrator.d.ts +47 -0
  54. package/dist/validation-orchestrator.js +145 -0
  55. package/dist/validation-prompts.d.ts +15 -0
  56. package/dist/validation-prompts.js +52 -0
  57. package/dist/validation-report.d.ts +57 -0
  58. package/dist/validation-report.js +129 -0
  59. package/dist/validation-tools.d.ts +7 -0
  60. package/dist/validation-tools.js +198 -0
  61. package/package.json +25 -10
  62. 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;
@@ -0,0 +1,5 @@
1
+ import { realpathSync } from "fs";
2
+ import { pathToFileURL } from "url";
3
+ export function entrypointFileURL(path) {
4
+ return path ? pathToFileURL(realpathSync(path)).href : "";
5
+ }
@@ -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
- try {
76
- process.kill(-pid, "SIGTERM");
81
+ if (process.platform === "win32") {
82
+ killWindowsProcessTree(pid);
77
83
  }
78
- catch {
79
- /* ESRCH ok */
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
- try {
86
- process.kill(-pid, "SIGKILL");
96
+ if (process.platform === "win32") {
97
+ killWindowsProcessTree(pid);
87
98
  }
88
- catch {
89
- /* ESRCH ok */
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 = spawn(command, args, {
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;