llm-cli-gateway 2.13.1 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  All notable changes to the llm-cli-gateway project.
4
4
 
5
+ ## [2.13.2] - 2026-07-01: remote HTTP + OAuth connector UX and hardening
6
+
7
+ ### Added
8
+
9
+ - **`remote_http_oauth` readiness projection in `doctor --json`.** A stable,
10
+ ordered 8-stage decision tree (`not_started`, `missing_public_url`,
11
+ `endpoint_unreachable`, `oauth_disabled`, `unsafe_oauth_config`,
12
+ `missing_oauth_client`, `missing_workspace`, `ready`) with deterministic,
13
+ secret-free `next_actions`, the copy-safe connector URLs, `oauth.consent_required`,
14
+ and a path-free workspace summary. Validated by `setup/status.schema.json`.
15
+ - **`llm-cli-gateway connector setup` command.** Emits a copy-safe JSON connector
16
+ packet (MCP URL, authorization URL, token URL, client id, workspace guidance)
17
+ plus a human summary, reusing the readiness projection. The deprecated no-auth
18
+ connector URL is omitted unless `--include-legacy-no-auth` is passed.
19
+ - **Centralized remote URL construction (`src/remote-url.ts`).** doctor, the
20
+ connector packet, the CLI OAuth output, the installer, and the runtime OAuth
21
+ well-known metadata + `WWW-Authenticate` challenge all derive URLs from one
22
+ helper so they cannot drift.
23
+
24
+ ### Changed
25
+
26
+ - **OAuth-first remote connector docs and setup UI.** The ChatGPT guide,
27
+ endpoint-exposure runbook, `ENDPOINT_EXPOSURE.md`, and setup UI lead with the
28
+ OAuth path keyed on `remote_http_oauth.stage`; the no-auth connector is
29
+ compatibility-only. `oauth client add` validates the redirect URI and client
30
+ id before writing, refuses duplicate ids, and labels copy-once secret output.
31
+
32
+ ### Security
33
+
34
+ - **F17 hardening.** The unsafe-OAuth (public clients / `open_dev`) start gate
35
+ now fails closed when the gateway is publicly exposed via a public URL or
36
+ tunnel, not only on a non-loopback bind.
37
+ - **No local-path disclosure to remote clients.** Remote MCP `workspace_*`
38
+ tools, `session_get`, `sessions://*` resources, and worktree response paths
39
+ are path-redacted for remote HTTP/OAuth callers (local operators keep absolute
40
+ paths; resume metadata is unaffected). `workspace_register_existing_repo` no
41
+ longer acts as a filesystem existence oracle.
42
+ - **Installer URL redaction.** The Go installer derives OAuth/MCP URLs from a
43
+ clean origin and strips userinfo/query/fragment from the stored public URL, so
44
+ no credential-bearing URL is emitted.
45
+ - **gitleaks allowlist** now covers the `gateway-logger` redaction test fixtures
46
+ (deliberate fake tokens), restoring a green secret-scan gate.
47
+
5
48
  ## [2.13.1] - 2026-07-01: code-scanning hardening and review-gate discipline
6
49
 
7
50
  ### Security
package/dist/config.d.ts CHANGED
@@ -162,4 +162,12 @@ export interface AcpConfig {
162
162
  };
163
163
  }
164
164
  export declare function loadAcpConfig(logger?: Logger): AcpConfig;
165
+ export type RemoteOAuthConfigStatus = "absent" | "disabled" | "enabled" | "malformed";
166
+ export interface RemoteOAuthConfigDiagnostics {
167
+ config: RemoteOAuthConfig;
168
+ status: RemoteOAuthConfigStatus;
169
+ configured: boolean;
170
+ issues: string[];
171
+ }
172
+ export declare function diagnoseRemoteOAuthConfig(logger?: Logger, env?: NodeJS.ProcessEnv): RemoteOAuthConfigDiagnostics;
165
173
  export declare function loadRemoteOAuthConfig(logger?: Logger, env?: NodeJS.ProcessEnv): RemoteOAuthConfig;
package/dist/config.js CHANGED
@@ -663,11 +663,7 @@ function disabledOAuthConfig(sourcePath = null, envOverrides = []) {
663
663
  function isSafeRedirectUri(uri) {
664
664
  return isHttpsOrLoopbackUrl(uri);
665
665
  }
666
- export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
667
- const configPath = defaultGatewayConfigPath();
668
- const { parsed: configFile, sourcePath } = readGatewayTomlFile(configPath, logger, "OAuth");
669
- const rawHttp = configFile?.http ?? {};
670
- const rawOAuth = rawHttp.oauth ?? {};
666
+ function mergeOAuthEnvOverrides(rawOAuth, env) {
671
667
  const envOverrides = [];
672
668
  const merged = { ...rawOAuth };
673
669
  if (env.LLM_GATEWAY_OAUTH_ENABLED !== undefined) {
@@ -695,43 +691,43 @@ export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
695
691
  merged.require_consent = merged.require_consent ?? true;
696
692
  envOverrides.push("LLM_GATEWAY_OAUTH_CONSENT_SECRET");
697
693
  }
698
- const parsed = OAuthConfigSchema.safeParse(merged);
699
- if (!parsed.success) {
700
- logWarn(logger, "Invalid [http.oauth] config; remote OAuth disabled", {
701
- error: parsed.error.message,
702
- });
703
- return disabledOAuthConfig(sourcePath, envOverrides);
704
- }
705
- const data = parsed.data;
694
+ return { merged, envOverrides };
695
+ }
696
+ function validateParsedOAuthConfig(data, env, sourcePath, envOverrides, logger, issues) {
706
697
  if (data.issuer !== "auto" && !isHttpsOrLoopbackUrl(data.issuer)) {
707
698
  logWarn(logger, "Invalid [http.oauth].issuer; remote OAuth disabled");
708
- return disabledOAuthConfig(sourcePath, envOverrides);
699
+ issues.push("OAuth issuer must be an https:// URL (or a loopback URL for local testing).");
700
+ return null;
709
701
  }
710
702
  for (const client of data.clients) {
711
703
  if (!data.allow_public_clients && !client.client_secret_hash) {
712
704
  logWarn(logger, "OAuth client secret hash is required when public clients are disabled", {
713
705
  client_id: client.client_id,
714
706
  });
715
- return disabledOAuthConfig(sourcePath, envOverrides);
707
+ issues.push("An OAuth client is missing a client_secret_hash (required when public clients are disabled). Recreate it with `llm-cli-gateway oauth client add`.");
708
+ return null;
716
709
  }
717
710
  if (client.client_secret_hash && !isSecretHash(client.client_secret_hash)) {
718
711
  logWarn(logger, "Invalid OAuth client secret hash; remote OAuth disabled", {
719
712
  client_id: client.client_id,
720
713
  });
721
- return disabledOAuthConfig(sourcePath, envOverrides);
714
+ issues.push("An OAuth client secret hash is not a valid scrypt hash. Rotate it with `llm-cli-gateway oauth client rotate`.");
715
+ return null;
722
716
  }
723
717
  if (client.allowed_redirect_uris.length === 0 ||
724
718
  client.allowed_redirect_uris.some(uri => !isSafeRedirectUri(uri))) {
725
719
  logWarn(logger, "Invalid OAuth client redirect URI; remote OAuth disabled", {
726
720
  client_id: client.client_id,
727
721
  });
728
- return disabledOAuthConfig(sourcePath, envOverrides);
722
+ issues.push("An OAuth client has a missing or non-https/loopback redirect URI. Re-add the client with a valid --redirect-uri.");
723
+ return null;
729
724
  }
730
725
  }
731
726
  if (data.shared_secret?.enabled) {
732
727
  if (!data.shared_secret.secret_hash || !isSecretHash(data.shared_secret.secret_hash)) {
733
728
  logWarn(logger, "Invalid [http.oauth.shared_secret] secret_hash; remote OAuth disabled");
734
- return disabledOAuthConfig(sourcePath, envOverrides);
729
+ issues.push("The OAuth shared-secret hash is missing or invalid. Reset it with `llm-cli-gateway oauth shared-secret set`.");
730
+ return null;
735
731
  }
736
732
  }
737
733
  if (data.registration_policy === "open_dev" && env.LLM_GATEWAY_OAUTH_OPEN_DEV !== "1") {
@@ -740,7 +736,8 @@ export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
740
736
  if (data.require_consent) {
741
737
  if (!data.consent_secret_hash || !isSecretHash(data.consent_secret_hash)) {
742
738
  logWarn(logger, "[http.oauth].require_consent is set but consent_secret_hash is missing/invalid; remote OAuth disabled");
743
- return disabledOAuthConfig(sourcePath, envOverrides);
739
+ issues.push("require_consent is set but the consent secret hash is missing or invalid. Set it with `llm-cli-gateway oauth shared-secret set` or LLM_GATEWAY_OAUTH_CONSENT_SECRET.");
740
+ return null;
744
741
  }
745
742
  }
746
743
  return {
@@ -769,3 +766,45 @@ export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
769
766
  sources: { configFile: sourcePath, envOverrides },
770
767
  };
771
768
  }
769
+ export function diagnoseRemoteOAuthConfig(logger = noopLogger, env = process.env) {
770
+ const configPath = defaultGatewayConfigPath();
771
+ const { parsed: configFile, sourcePath } = readGatewayTomlFile(configPath, logger, "OAuth");
772
+ const rawHttp = configFile?.http ?? {};
773
+ const rawOAuth = rawHttp.oauth ?? {};
774
+ const { merged, envOverrides } = mergeOAuthEnvOverrides(rawOAuth, env);
775
+ const configured = Object.keys(rawOAuth).length > 0 || envOverrides.length > 0;
776
+ const parsed = OAuthConfigSchema.safeParse(merged);
777
+ if (!parsed.success) {
778
+ logWarn(logger, "Invalid [http.oauth] config; remote OAuth disabled", {
779
+ error: parsed.error.message,
780
+ });
781
+ return {
782
+ config: disabledOAuthConfig(sourcePath, envOverrides),
783
+ status: "malformed",
784
+ configured: true,
785
+ issues: [
786
+ "The [http.oauth] config is invalid (schema validation failed); remote OAuth is disabled.",
787
+ ],
788
+ };
789
+ }
790
+ const data = parsed.data;
791
+ const issues = [];
792
+ const built = validateParsedOAuthConfig(data, env, sourcePath, envOverrides, logger, issues);
793
+ if (!built) {
794
+ return {
795
+ config: disabledOAuthConfig(sourcePath, envOverrides),
796
+ status: data.enabled ? "malformed" : configured ? "disabled" : "absent",
797
+ configured,
798
+ issues: data.enabled ? issues : [],
799
+ };
800
+ }
801
+ const status = built.enabled
802
+ ? "enabled"
803
+ : configured
804
+ ? "disabled"
805
+ : "absent";
806
+ return { config: built, status, configured, issues: [] };
807
+ }
808
+ export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
809
+ return diagnoseRemoteOAuthConfig(logger, env).config;
810
+ }
@@ -0,0 +1,39 @@
1
+ import { type RemoteHttpOAuthReadiness } from "./doctor.js";
2
+ import type { RemoteOAuthConfig } from "./auth.js";
3
+ export declare 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.";
4
+ export interface ConnectorSetupOptions {
5
+ clientId?: string;
6
+ includeLegacyNoAuth?: boolean;
7
+ }
8
+ export interface ConnectorSetupPacket {
9
+ ok: boolean;
10
+ schema: "remote-connector-setup.v1";
11
+ ready: boolean;
12
+ stage: RemoteHttpOAuthReadiness["stage"];
13
+ auth_mode: RemoteHttpOAuthReadiness["auth_mode"];
14
+ connector: {
15
+ mcp_url: string | null;
16
+ authorization_url: string | null;
17
+ token_url: string | null;
18
+ client_id: string | null;
19
+ client_secret_required: boolean;
20
+ client_secret_source: string | null;
21
+ };
22
+ workspace: RemoteHttpOAuthReadiness["workspace"];
23
+ next_actions: string[];
24
+ warnings: string[];
25
+ legacy_no_auth?: {
26
+ deprecated: true;
27
+ connector_url: string | null;
28
+ note: string;
29
+ };
30
+ }
31
+ export declare function buildConnectorSetupPacket(input: {
32
+ readiness: RemoteHttpOAuthReadiness;
33
+ oauth: RemoteOAuthConfig;
34
+ options?: ConnectorSetupOptions;
35
+ legacyNoAuthUrl?: string | null;
36
+ }): ConnectorSetupPacket;
37
+ export declare function legacyNoAuthConnectorUrl(env?: NodeJS.ProcessEnv): string | null;
38
+ export declare function gatherConnectorSetupPacket(options?: ConnectorSetupOptions, env?: NodeJS.ProcessEnv): ConnectorSetupPacket;
39
+ export declare function renderConnectorSetupSummary(packet: ConnectorSetupPacket): string;
@@ -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
@@ -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;