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 +43 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +58 -19
- package/dist/connector-setup.d.ts +39 -0
- package/dist/connector-setup.js +86 -0
- package/dist/doctor.d.ts +39 -1
- package/dist/doctor.js +182 -3
- package/dist/http-transport.js +27 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +149 -25
- package/dist/oauth.js +15 -14
- package/dist/remote-url.d.ts +28 -0
- package/dist/remote-url.js +61 -0
- package/dist/resources.js +3 -1
- package/dist/session-manager.d.ts +2 -0
- package/dist/session-manager.js +20 -1
- package/dist/workspace-registry.d.ts +9 -0
- package/dist/workspace-registry.js +24 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/setup/status.schema.json +75 -0
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
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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),
|
package/dist/http-transport.js
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
`
|
|
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;
|