llm-cli-gateway 2.13.0 → 2.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ import { gatherRemoteHttpOAuthReadiness } from "./doctor.js";
2
+ import { diagnoseRemoteOAuthConfig } from "./config.js";
3
+ import { joinBaseAndPath, resolveConfiguredRemoteOrigin } from "./remote-url.js";
4
+ export const CONNECTOR_SETUP_SECRET_WARNING = "Never paste gateway bearer tokens, OAuth client secrets, OAuth access tokens, consent/shared secrets, tunnel tokens, or provider credentials into a remote chat transcript. Only the fields in this packet are safe to paste into a connector UI.";
5
+ export function buildConnectorSetupPacket(input) {
6
+ const { readiness, oauth } = input;
7
+ const options = input.options ?? {};
8
+ const configuredClientId = oauth.clients[0]?.clientId ?? null;
9
+ const clientId = options.clientId ?? configuredClientId;
10
+ const clientSecretRequired = oauth.enabled && !oauth.allowPublicClients;
11
+ const clientSecretSource = clientSecretRequired
12
+ ? "Run `llm-cli-gateway oauth client add <client-id> --redirect-uri <connector-callback> --print-once` (or `oauth client rotate <client-id> --print-once`) and paste the printed secret once into the connector UI."
13
+ : null;
14
+ const packet = {
15
+ ok: true,
16
+ schema: "remote-connector-setup.v1",
17
+ ready: readiness.ready,
18
+ stage: readiness.stage,
19
+ auth_mode: readiness.auth_mode,
20
+ connector: {
21
+ mcp_url: readiness.mcp_url,
22
+ authorization_url: readiness.oauth.authorization_url,
23
+ token_url: readiness.oauth.token_url,
24
+ client_id: clientId,
25
+ client_secret_required: clientSecretRequired,
26
+ client_secret_source: clientSecretSource,
27
+ },
28
+ workspace: readiness.workspace,
29
+ next_actions: readiness.next_actions,
30
+ warnings: [CONNECTOR_SETUP_SECRET_WARNING],
31
+ };
32
+ if (options.includeLegacyNoAuth) {
33
+ packet.legacy_no_auth = {
34
+ deprecated: true,
35
+ connector_url: input.legacyNoAuthUrl ?? null,
36
+ note: "Deprecated no-auth connector URL. It bypasses authentication; prefer OAuth. Do not share this URL.",
37
+ };
38
+ }
39
+ return packet;
40
+ }
41
+ export function legacyNoAuthConnectorUrl(env = process.env) {
42
+ const noAuthPath = (env.LLM_GATEWAY_NO_AUTH_PATHS || "")
43
+ .split(/[,;\s]+/)
44
+ .map(value => value.trim())
45
+ .find(value => value.startsWith("/") && !value.includes("?") && !value.includes("#"));
46
+ if (!noAuthPath)
47
+ return null;
48
+ const baseOrigin = resolveConfiguredRemoteOrigin({
49
+ publicUrl: env.LLM_GATEWAY_PUBLIC_URL ?? null,
50
+ });
51
+ if (!baseOrigin)
52
+ return null;
53
+ return joinBaseAndPath(baseOrigin, noAuthPath);
54
+ }
55
+ export function gatherConnectorSetupPacket(options = {}, env = process.env) {
56
+ const readiness = gatherRemoteHttpOAuthReadiness(env);
57
+ const oauth = diagnoseRemoteOAuthConfig(undefined, env).config;
58
+ const legacyNoAuthUrl = options.includeLegacyNoAuth ? legacyNoAuthConnectorUrl(env) : null;
59
+ return buildConnectorSetupPacket({ readiness, oauth, options, legacyNoAuthUrl });
60
+ }
61
+ export function renderConnectorSetupSummary(packet) {
62
+ const lines = [];
63
+ lines.push(`Remote connector readiness: ${packet.stage}${packet.ready ? " (ready)" : ""}`);
64
+ lines.push(`Authentication mode: ${packet.auth_mode}`);
65
+ if (packet.connector.mcp_url)
66
+ lines.push(`MCP URL: ${packet.connector.mcp_url}`);
67
+ if (packet.connector.authorization_url)
68
+ lines.push(`Authorization URL: ${packet.connector.authorization_url}`);
69
+ if (packet.connector.token_url)
70
+ lines.push(`Token URL: ${packet.connector.token_url}`);
71
+ if (packet.connector.client_id)
72
+ lines.push(`Client ID: ${packet.connector.client_id}`);
73
+ if (packet.connector.client_secret_required && packet.connector.client_secret_source) {
74
+ lines.push(`Client secret: ${packet.connector.client_secret_source}`);
75
+ }
76
+ lines.push(`Workspace: ${packet.workspace.ready
77
+ ? `ready (default: ${packet.workspace.default ?? "none, pass a registered alias"})`
78
+ : "not ready"}`);
79
+ if (packet.next_actions.length > 0) {
80
+ lines.push("Next actions:");
81
+ for (const action of packet.next_actions)
82
+ lines.push(` - ${action}`);
83
+ }
84
+ lines.push(packet.warnings[0] ?? CONNECTOR_SETUP_SECRET_WARNING);
85
+ return lines.join("\n");
86
+ }
package/dist/doctor.d.ts CHANGED
@@ -2,7 +2,9 @@ import { type EndpointExposureReport } from "./endpoint-exposure.js";
2
2
  import { type ProviderLoginStatus } from "./provider-status.js";
3
3
  import { type ApiProviderLoginGuidance } from "./provider-login-guidance.js";
4
4
  import type { FlightRecorderQuery } from "./flight-recorder.js";
5
- import { type CacheAwarenessConfig, type ProvidersConfig } from "./config.js";
5
+ import { type CacheAwarenessConfig, type ProvidersConfig, type RemoteOAuthConfigDiagnostics } from "./config.js";
6
+ import type { AuthConfig } from "./auth.js";
7
+ import { type RemoteSafeWorkspaceSummary } from "./workspace-registry.js";
6
8
  import { type ProviderCapabilityId, type ProviderKind } from "./provider-tool-capabilities.js";
7
9
  import { type CliType } from "./session-manager.js";
8
10
  export interface CacheAwarenessReport {
@@ -146,6 +148,7 @@ export interface DoctorReport {
146
148
  };
147
149
  }>;
148
150
  endpoint_exposure: EndpointExposureReport;
151
+ remote_http_oauth: RemoteHttpOAuthReadiness;
149
152
  client_config: {
150
153
  claude_desktop_config_present: boolean;
151
154
  codex_config_present: boolean;
@@ -167,6 +170,41 @@ export interface DoctorReport {
167
170
  };
168
171
  next_actions: string[];
169
172
  }
173
+ export type RemoteHttpOAuthStage = "not_started" | "missing_public_url" | "endpoint_unreachable" | "oauth_disabled" | "unsafe_oauth_config" | "missing_oauth_client" | "missing_workspace" | "ready";
174
+ export declare const REMOTE_HTTP_OAUTH_STAGES: readonly RemoteHttpOAuthStage[];
175
+ export interface RemoteHttpOAuthReadiness {
176
+ ready: boolean;
177
+ stage: RemoteHttpOAuthStage;
178
+ public_url: string | null;
179
+ mcp_url: string | null;
180
+ auth_mode: "oauth" | "bearer_token" | "none";
181
+ oauth: {
182
+ enabled: boolean;
183
+ issuer: string | null;
184
+ authorization_url: string | null;
185
+ token_url: string | null;
186
+ registration_policy: string;
187
+ clients_configured: number;
188
+ consent_required: boolean;
189
+ };
190
+ workspace: {
191
+ ready: boolean;
192
+ default: string | null;
193
+ aliases: string[];
194
+ };
195
+ next_actions: string[];
196
+ }
197
+ export interface RemoteHttpOAuthReadinessInput {
198
+ oauthDiag: Pick<RemoteOAuthConfigDiagnostics, "status" | "config" | "issues">;
199
+ workspace: RemoteSafeWorkspaceSummary;
200
+ auth: AuthConfig;
201
+ transport: "stdio" | "http";
202
+ publicUrl: string | null;
203
+ endpoint: EndpointExposureReport;
204
+ mcpPath: string;
205
+ }
206
+ export declare function buildRemoteHttpOAuthReadiness(input: RemoteHttpOAuthReadinessInput): RemoteHttpOAuthReadiness;
207
+ export declare function gatherRemoteHttpOAuthReadiness(env?: NodeJS.ProcessEnv): RemoteHttpOAuthReadiness;
170
208
  export interface CreateDoctorReportOptions {
171
209
  env?: NodeJS.ProcessEnv;
172
210
  flightRecorder?: FlightRecorderQuery;
package/dist/doctor.js CHANGED
@@ -7,8 +7,9 @@ import { createEndpointExposureReport, redactDiagnosticUrl, } from "./endpoint-e
7
7
  import { getApiProviderStatus, listProviderRuntimeStatuses, } from "./provider-status.js";
8
8
  import { getApiProviderLoginGuidance, } from "./provider-login-guidance.js";
9
9
  import { CLAUDE_MCP_SERVER_NAMES } from "./claude-mcp-config.js";
10
- import { enabledApiProviders, loadCacheAwarenessConfig, loadProvidersConfig, loadRemoteOAuthConfig, } from "./config.js";
11
- import { loadWorkspaceRegistry } from "./workspace-registry.js";
10
+ import { diagnoseRemoteOAuthConfig, enabledApiProviders, loadCacheAwarenessConfig, loadProvidersConfig, } from "./config.js";
11
+ import { loadWorkspaceRegistry, remoteSafeWorkspaceSummary, } from "./workspace-registry.js";
12
+ import { buildRemoteConnectorUrls, resolveConfiguredRemoteOrigin } from "./remote-url.js";
12
13
  import { computeGlobalCacheStats } from "./cache-stats.js";
13
14
  import { FlightRecorder, resolveFlightRecorderDbPath } from "./flight-recorder.js";
14
15
  import { buildUpstreamContractReport } from "./upstream-contracts.js";
@@ -136,6 +137,174 @@ export function checkGeminiConfig(cwd = process.cwd(), home = homedir(), whiteli
136
137
  next_actions: nextActions,
137
138
  };
138
139
  }
140
+ export const REMOTE_HTTP_OAUTH_STAGES = [
141
+ "not_started",
142
+ "missing_public_url",
143
+ "endpoint_unreachable",
144
+ "oauth_disabled",
145
+ "unsafe_oauth_config",
146
+ "missing_oauth_client",
147
+ "missing_workspace",
148
+ "ready",
149
+ ];
150
+ export function buildRemoteHttpOAuthReadiness(input) {
151
+ const { config: oauth, status, issues } = input.oauthDiag;
152
+ const e = input.endpoint;
153
+ const baseOrigin = resolveConfiguredRemoteOrigin({
154
+ issuer: oauth.issuer === "auto" ? null : redactDiagnosticUrl(oauth.issuer),
155
+ publicUrl: input.publicUrl,
156
+ });
157
+ const urls = buildRemoteConnectorUrls({
158
+ baseOrigin,
159
+ mcpPath: input.mcpPath,
160
+ oauthEnabled: oauth.enabled,
161
+ });
162
+ const hasValidPublicHttpsUrl = e.public_url_configured &&
163
+ e.https_configured &&
164
+ (e.mode === "tunnel" || e.mode === "byo_reverse_proxy");
165
+ const oauthNotEnabled = status === "absent" || status === "disabled";
166
+ const oauthUnsafeForRemote = status === "malformed" ||
167
+ (oauth.enabled && (oauth.allowPublicClients || oauth.registrationPolicy === "open_dev"));
168
+ let stage;
169
+ if (!e.public_url_configured && input.transport !== "http" && status === "absent") {
170
+ stage = "not_started";
171
+ }
172
+ else if (!hasValidPublicHttpsUrl) {
173
+ stage = "missing_public_url";
174
+ }
175
+ else if (e.reachable_from_web === "unreachable") {
176
+ stage = "endpoint_unreachable";
177
+ }
178
+ else if (oauthNotEnabled) {
179
+ stage = "oauth_disabled";
180
+ }
181
+ else if (oauthUnsafeForRemote) {
182
+ stage = "unsafe_oauth_config";
183
+ }
184
+ else if (oauth.registrationPolicy === "static_clients" && oauth.clients.length === 0) {
185
+ stage = "missing_oauth_client";
186
+ }
187
+ else if (!input.workspace.ready) {
188
+ stage = "missing_workspace";
189
+ }
190
+ else {
191
+ stage = "ready";
192
+ }
193
+ const authMode = oauth.enabled
194
+ ? "oauth"
195
+ : input.auth.required && input.auth.tokenConfigured
196
+ ? "bearer_token"
197
+ : "none";
198
+ return {
199
+ ready: stage === "ready",
200
+ stage,
201
+ public_url: input.publicUrl,
202
+ mcp_url: urls.mcpUrl,
203
+ auth_mode: authMode,
204
+ oauth: {
205
+ enabled: oauth.enabled,
206
+ issuer: urls.issuer,
207
+ authorization_url: urls.authorizationUrl,
208
+ token_url: urls.tokenUrl,
209
+ registration_policy: oauth.registrationPolicy,
210
+ clients_configured: oauth.clients.length,
211
+ consent_required: oauth.requireConsent,
212
+ },
213
+ workspace: {
214
+ ready: input.workspace.ready,
215
+ default: input.workspace.default,
216
+ aliases: input.workspace.aliases,
217
+ },
218
+ next_actions: remoteReadinessNextActions(stage, {
219
+ oauth,
220
+ status,
221
+ issues,
222
+ workspace: input.workspace,
223
+ endpointReachability: e.reachable_from_web,
224
+ }),
225
+ };
226
+ }
227
+ function remoteReadinessNextActions(stage, ctx) {
228
+ switch (stage) {
229
+ case "not_started":
230
+ return [
231
+ "Remote HTTP + OAuth setup has not started. Start an HTTPS tunnel or reverse proxy and set LLM_GATEWAY_PUBLIC_URL to the public https URL.",
232
+ "Then register an OAuth client: llm-cli-gateway oauth client add <client-id> --redirect-uri <connector-callback> --print-once",
233
+ ];
234
+ case "missing_public_url":
235
+ return [
236
+ "Set LLM_GATEWAY_PUBLIC_URL to a public https URL (tunnel or reverse proxy), not localhost or a LAN address.",
237
+ "Re-run: llm-cli-gateway doctor --json",
238
+ ];
239
+ case "endpoint_unreachable":
240
+ return [
241
+ "The public MCP URL is configured but not reachable from the web. Fix tunnel/reverse-proxy routing, then re-run: llm-cli-gateway doctor --json",
242
+ ];
243
+ case "oauth_disabled":
244
+ return [
245
+ "OAuth is the recommended remote connector authentication mode. Enable it by registering a client: llm-cli-gateway oauth client add <client-id> --redirect-uri <connector-callback> --print-once",
246
+ ];
247
+ case "unsafe_oauth_config": {
248
+ const reasons = [];
249
+ if (ctx.status === "malformed") {
250
+ reasons.push(...(ctx.issues.length
251
+ ? ctx.issues
252
+ : ["The [http.oauth] config is invalid; fix it and re-run doctor --json."]));
253
+ }
254
+ else {
255
+ if (ctx.oauth.allowPublicClients) {
256
+ reasons.push("OAuth allow_public_clients is enabled on a public endpoint. Use confidential clients: registration_policy=static_clients with a client secret.");
257
+ }
258
+ if (ctx.oauth.registrationPolicy === "open_dev") {
259
+ reasons.push("OAuth registration_policy=open_dev is unsafe on a public endpoint. Switch to static_clients with confidential client secrets.");
260
+ }
261
+ }
262
+ reasons.push("Re-run: llm-cli-gateway doctor --json");
263
+ return reasons;
264
+ }
265
+ case "missing_oauth_client":
266
+ return [
267
+ "OAuth is enabled but no client is registered. Add one: llm-cli-gateway oauth client add <client-id> --redirect-uri <connector-callback> --print-once",
268
+ ];
269
+ case "missing_workspace":
270
+ return [
271
+ "No workspace is available for remote provider execution. Register a repo and set it as the default: add a [[workspaces.repos]] entry with [workspaces].default in ~/.llm-cli-gateway/config.toml, or run `llm-cli-gateway workspace add <alias> <absolute-repo-path> --default` when an allowed root is configured.",
272
+ "Remote clients then select the workspace by alias; local absolute paths are never accepted from remote clients.",
273
+ ];
274
+ case "ready": {
275
+ const readyActions = [
276
+ ctx.workspace.default
277
+ ? `Remote connector is ready. Remote provider calls use the default workspace "${ctx.workspace.default}" unless a registered alias is supplied.`
278
+ : "Remote connector is ready. Remote provider calls must supply a registered workspace alias; set a default with `llm-cli-gateway workspace add <alias> <path> --default` to make one implicit.",
279
+ "Paste only copy-safe connector fields (MCP URL, authorization URL, token URL, client id) into the remote connector UI.",
280
+ ];
281
+ if (ctx.endpointReachability === "not_checked") {
282
+ readyActions.push("Config is complete but public reachability was not verified. Set LLM_GATEWAY_VERIFY_PUBLIC_URL=1 and re-run doctor --json to confirm the public URL is reachable from the web.");
283
+ }
284
+ return readyActions;
285
+ }
286
+ default:
287
+ return [];
288
+ }
289
+ }
290
+ export function gatherRemoteHttpOAuthReadiness(env = process.env) {
291
+ const oauthDiag = diagnoseRemoteOAuthConfig(undefined, env);
292
+ const workspace = remoteSafeWorkspaceSummary(loadWorkspaceRegistry());
293
+ const auth = loadAuthConfig(env);
294
+ const transport = defaultTransport(env);
295
+ const publicUrl = redactDiagnosticUrl(env.LLM_GATEWAY_PUBLIC_URL || null);
296
+ const endpoint = createEndpointExposureReport(env, publicUrl);
297
+ const mcpPath = env.LLM_GATEWAY_HTTP_PATH || "/mcp";
298
+ return buildRemoteHttpOAuthReadiness({
299
+ oauthDiag,
300
+ workspace,
301
+ auth,
302
+ transport,
303
+ publicUrl,
304
+ endpoint,
305
+ mcpPath,
306
+ });
307
+ }
139
308
  function packageVersion() {
140
309
  const here = dirname(fileURLToPath(import.meta.url));
141
310
  const candidates = [join(here, "..", "package.json"), join(here, "..", "..", "package.json")];
@@ -306,7 +475,8 @@ export function createDoctorReport(envOrOptions = process.env) {
306
475
  : { env: envOrOptions };
307
476
  const env = opts.env ?? process.env;
308
477
  const auth = loadAuthConfig(env);
309
- const oauth = loadRemoteOAuthConfig(undefined, env);
478
+ const oauthDiag = diagnoseRemoteOAuthConfig(undefined, env);
479
+ const oauth = oauthDiag.config;
310
480
  const workspaceRegistry = loadWorkspaceRegistry();
311
481
  const transport = defaultTransport(env);
312
482
  const rawPublicUrl = env.LLM_GATEWAY_PUBLIC_URL || null;
@@ -382,6 +552,15 @@ export function createDoctorReport(envOrOptions = process.env) {
382
552
  },
383
553
  providers: Object.fromEntries(CLI_TYPES.map(provider => [provider, doctorProviderStatus(providerStatuses[provider])])),
384
554
  endpoint_exposure: endpointExposure,
555
+ remote_http_oauth: buildRemoteHttpOAuthReadiness({
556
+ oauthDiag,
557
+ workspace: remoteSafeWorkspaceSummary(workspaceRegistry),
558
+ auth,
559
+ transport,
560
+ publicUrl,
561
+ endpoint: endpointExposure,
562
+ mcpPath: env.LLM_GATEWAY_HTTP_PATH || "/mcp",
563
+ }),
385
564
  client_config: clientConfigStatus(),
386
565
  cache_awareness: buildCacheAwarenessReport(opts),
387
566
  provider_capabilities: buildProviderCapabilitySummary(providerStatuses),
@@ -57,6 +57,27 @@ function isLocalHost(host) {
57
57
  const hostname = host.split(":")[0]?.toLowerCase() ?? "";
58
58
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
59
59
  }
60
+ function isLoopbackHostname(hostname) {
61
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
62
+ return h === "localhost" || h === "::1" || /^127\./.test(h);
63
+ }
64
+ function isPubliclyExposed(env, host) {
65
+ if (!isLocalHost(host))
66
+ return true;
67
+ if (env.LLM_GATEWAY_TUNNEL_PROVIDER && env.LLM_GATEWAY_TUNNEL_PROVIDER.trim().length > 0) {
68
+ return true;
69
+ }
70
+ const publicUrl = env.LLM_GATEWAY_PUBLIC_URL;
71
+ if (publicUrl && publicUrl.trim().length > 0) {
72
+ try {
73
+ if (!isLoopbackHostname(new URL(publicUrl).hostname))
74
+ return true;
75
+ }
76
+ catch {
77
+ }
78
+ }
79
+ return false;
80
+ }
60
81
  function requestBaseUrl(req) {
61
82
  const configured = process.env.LLM_GATEWAY_PUBLIC_URL;
62
83
  if (configured) {
@@ -85,9 +106,12 @@ export async function startHttpGateway(options) {
85
106
  const oauthConfig = loadRemoteOAuthConfig(logger);
86
107
  if (oauthConfig.enabled &&
87
108
  (oauthConfig.allowPublicClients || oauthConfig.registrationPolicy === "open_dev") &&
88
- !isLocalHost(host)) {
89
- throw new Error(`Refusing to start: remote OAuth with ${oauthConfig.allowPublicClients ? "public clients" : "open_dev registration"} is exposed on a non-loopback bind (host=${host}). Bind LLM_GATEWAY_HTTP_HOST to 127.0.0.1 ` +
90
- `and front the gateway with an authenticating proxy, or switch to ` +
109
+ isPubliclyExposed(process.env, host)) {
110
+ const exposure = !isLocalHost(host)
111
+ ? `a non-loopback bind (host=${host})`
112
+ : "a public URL / tunnel (LLM_GATEWAY_PUBLIC_URL or LLM_GATEWAY_TUNNEL_PROVIDER)";
113
+ throw new Error(`Refusing to start: remote OAuth with ${oauthConfig.allowPublicClients ? "public clients" : "open_dev registration"} is exposed via ${exposure}. A loopback bind fronted by a public tunnel is still ` +
114
+ `publicly reachable. Front the gateway with an authenticating proxy, or switch to ` +
91
115
  `registration_policy=static_clients with confidential client secrets.`);
92
116
  }
93
117
  const oauthServer = oauthConfig.enabled
package/dist/index.d.ts CHANGED
@@ -38,11 +38,11 @@ type ExtendedToolResponse = {
38
38
  reviewIntegrity?: ReviewIntegrityResult;
39
39
  warnings?: WarningEntry[];
40
40
  };
41
- declare const logger: {
42
- info: (message: string, ...args: any[]) => void;
43
- warn: (message: string, ...args: any[]) => void;
44
- error: (message: string, ...args: any[]) => void;
45
- debug: (message: string, ...args: any[]) => void;
41
+ export declare const logger: {
42
+ info: (message: string, ...args: unknown[]) => void;
43
+ warn: (message: string, ...args: unknown[]) => void;
44
+ error: (message: string, ...args: unknown[]) => void;
45
+ debug: (message: string, ...args: unknown[]) => void;
46
46
  };
47
47
  type GatewayLogger = typeof logger;
48
48
  export declare function buildServerInstructions(asyncJobsEnabled: boolean, grokApiToolsEnabled?: boolean): string;
@@ -120,6 +120,7 @@ export declare function resolveWorktreeForRequest(worktreeOpt: boolean | {
120
120
  workspaceRoot?: string;
121
121
  }): Promise<ResolvedWorktree>;
122
122
  export declare function formatWorktreePrefix(worktreePath?: string): string;
123
+ export declare function remoteSafeWorktreePath(worktreePath?: string, workspaceRoot?: string): string | undefined;
123
124
  export declare function createErrorResponse(cli: string, code: number, stderr: string, correlationId?: string, error?: Error, apiError?: {
124
125
  httpStatus?: number | null;
125
126
  responseBody?: string;