llm-cli-gateway 2.4.0 → 2.5.0
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 +9 -0
- package/README.md +2 -0
- package/dist/auth.d.ts +44 -1
- package/dist/auth.js +60 -13
- package/dist/config.d.ts +2 -0
- package/dist/config.js +151 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +22 -11
- package/dist/http-transport.js +74 -12
- package/dist/index.d.ts +16 -1
- package/dist/index.js +554 -29
- package/dist/oauth.d.ts +38 -0
- package/dist/oauth.js +441 -0
- package/dist/request-context.d.ts +7 -0
- package/dist/request-context.js +8 -0
- package/dist/upstream-contracts.js +42 -26
- package/dist/workspace-registry.d.ts +63 -0
- package/dist/workspace-registry.js +417 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/setup/status.schema.json +42 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ All notable changes to the llm-cli-gateway project.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [2.5.0] - 2026-06-08: Remote connector OAuth and workspaces
|
|
8
|
+
|
|
9
|
+
- Added remote connector OAuth discovery and authorization-code support with
|
|
10
|
+
hash-only static client/shared-secret configuration, copy-once local secret
|
|
11
|
+
commands, and OAuth-first ChatGPT setup guidance.
|
|
12
|
+
- Added workspace registry and workspace creation surfaces so provider requests
|
|
13
|
+
can select registered repo aliases and create local folders/Git repos only
|
|
14
|
+
under configured allowed roots.
|
|
15
|
+
|
|
7
16
|
## [2.4.0] - 2026-06-08: Direct Grok API provider and provider-owned sessions
|
|
8
17
|
|
|
9
18
|
### Added
|
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ Or use directly with `npx` from an MCP client:
|
|
|
44
44
|
- Supports cache-aware `promptParts`, including explicit Claude `cache_control` when opted in.
|
|
45
45
|
- Can run requests inside gateway-managed git worktrees for isolated multi-agent review and implementation loops.
|
|
46
46
|
- Ships personal-appliance setup surfaces: HTTP transport with bearer-token auth, `doctor --json`, setup UI artifacts, provider setup snippets, Docker fallback, and checked release bundles.
|
|
47
|
+
- Remote web connectors use MCP OAuth discovery and authorization-code setup with static client or shared-secret gates. Client secrets are generated locally, stored only as hashes, and printed only by explicit copy-once commands.
|
|
48
|
+
- Provider CLI requests can select registered workspaces by alias via `workspace`; remote requests should use aliases, not arbitrary filesystem paths. New local folder/Git workspaces can be created only under configured allowed roots.
|
|
47
49
|
|
|
48
50
|
## Workflow Assets
|
|
49
51
|
|
package/dist/auth.d.ts
CHANGED
|
@@ -8,8 +8,51 @@ export interface AuthResult {
|
|
|
8
8
|
ok: boolean;
|
|
9
9
|
status?: number;
|
|
10
10
|
message?: string;
|
|
11
|
+
kind?: "disabled" | "gateway_bearer" | "oauth";
|
|
12
|
+
scopes?: string[];
|
|
13
|
+
clientId?: string;
|
|
11
14
|
}
|
|
15
|
+
export type OAuthRegistrationPolicy = "static_clients" | "shared_secret" | "open_dev";
|
|
16
|
+
export interface RemoteOAuthClientConfig {
|
|
17
|
+
clientId: string;
|
|
18
|
+
clientSecretHash: string | null;
|
|
19
|
+
allowedRedirectUris: string[];
|
|
20
|
+
scopes: string[];
|
|
21
|
+
}
|
|
22
|
+
export interface RemoteOAuthSharedSecretConfig {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
secretHash: string | null;
|
|
25
|
+
promptLabel: string;
|
|
26
|
+
}
|
|
27
|
+
export interface RemoteOAuthConfig {
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
issuer: string | "auto";
|
|
30
|
+
requirePkce: boolean;
|
|
31
|
+
allowPlainPkce: boolean;
|
|
32
|
+
registrationPolicy: OAuthRegistrationPolicy;
|
|
33
|
+
allowPublicClients: boolean;
|
|
34
|
+
tokenTtlSeconds: number;
|
|
35
|
+
clients: RemoteOAuthClientConfig[];
|
|
36
|
+
sharedSecret: RemoteOAuthSharedSecretConfig | null;
|
|
37
|
+
sources: {
|
|
38
|
+
configFile: string | null;
|
|
39
|
+
envOverrides: string[];
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export declare function timingSafeStringEqual(left: string, right: string): boolean;
|
|
12
43
|
export declare function loadAuthConfig(env?: NodeJS.ProcessEnv): AuthConfig;
|
|
13
44
|
export declare function getRequiredBearerToken(env?: NodeJS.ProcessEnv): string | null;
|
|
45
|
+
export declare function issueOAuthAccessToken(args: {
|
|
46
|
+
clientId: string;
|
|
47
|
+
scopes: string[];
|
|
48
|
+
ttlSeconds: number;
|
|
49
|
+
now?: number;
|
|
50
|
+
}): {
|
|
51
|
+
accessToken: string;
|
|
52
|
+
expiresIn: number;
|
|
53
|
+
scope: string;
|
|
54
|
+
};
|
|
14
55
|
export declare function authorizeBearerRequest(req: IncomingMessage, token?: string | null): AuthResult;
|
|
15
|
-
export declare function writeAuthFailure(res: ServerResponse, result: AuthResult
|
|
56
|
+
export declare function writeAuthFailure(res: ServerResponse, result: AuthResult, options?: {
|
|
57
|
+
resourceMetadataUrl?: string;
|
|
58
|
+
}): void;
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
1
2
|
const AUTH_SCHEME = "Bearer ";
|
|
3
|
+
const OAUTH_ACCESS_TOKEN_BYTES = 32;
|
|
4
|
+
const oauthAccessTokens = new Map();
|
|
5
|
+
export function timingSafeStringEqual(left, right) {
|
|
6
|
+
const leftBuffer = Buffer.from(left, "utf8");
|
|
7
|
+
const rightBuffer = Buffer.from(right, "utf8");
|
|
8
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
12
|
+
}
|
|
2
13
|
export function loadAuthConfig(env = process.env) {
|
|
3
14
|
const token = env.LLM_GATEWAY_AUTH_TOKEN;
|
|
4
15
|
const disabled = env.LLM_GATEWAY_AUTH_DISABLED === "1";
|
|
@@ -14,16 +25,32 @@ export function getRequiredBearerToken(env = process.env) {
|
|
|
14
25
|
return null;
|
|
15
26
|
return env.LLM_GATEWAY_AUTH_TOKEN || null;
|
|
16
27
|
}
|
|
28
|
+
export function issueOAuthAccessToken(args) {
|
|
29
|
+
const now = args.now ?? Date.now();
|
|
30
|
+
const ttlSeconds = Math.max(1, Math.floor(args.ttlSeconds));
|
|
31
|
+
const scopes = [...new Set(args.scopes.length ? args.scopes : ["mcp"])];
|
|
32
|
+
const accessToken = `oauth_${randomBytes(OAUTH_ACCESS_TOKEN_BYTES).toString("base64url")}`;
|
|
33
|
+
oauthAccessTokens.set(accessToken, {
|
|
34
|
+
clientId: args.clientId,
|
|
35
|
+
scopes,
|
|
36
|
+
issuedAt: now,
|
|
37
|
+
expiresAt: now + ttlSeconds * 1000,
|
|
38
|
+
});
|
|
39
|
+
return { accessToken, expiresIn: ttlSeconds, scope: scopes.join(" ") };
|
|
40
|
+
}
|
|
41
|
+
function validateOAuthAccessToken(token, now = Date.now()) {
|
|
42
|
+
const entry = oauthAccessTokens.get(token);
|
|
43
|
+
if (!entry)
|
|
44
|
+
return null;
|
|
45
|
+
if (entry.expiresAt <= now) {
|
|
46
|
+
oauthAccessTokens.delete(token);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
17
51
|
export function authorizeBearerRequest(req, token = getRequiredBearerToken()) {
|
|
18
52
|
if (!loadAuthConfig().required) {
|
|
19
|
-
return { ok: true };
|
|
20
|
-
}
|
|
21
|
-
if (!token) {
|
|
22
|
-
return {
|
|
23
|
-
ok: false,
|
|
24
|
-
status: 503,
|
|
25
|
-
message: "HTTP transport requires LLM_GATEWAY_AUTH_TOKEN",
|
|
26
|
-
};
|
|
53
|
+
return { ok: true, kind: "disabled", scopes: [] };
|
|
27
54
|
}
|
|
28
55
|
const header = req.headers.authorization;
|
|
29
56
|
const value = Array.isArray(header) ? header[0] : header;
|
|
@@ -31,16 +58,36 @@ export function authorizeBearerRequest(req, token = getRequiredBearerToken()) {
|
|
|
31
58
|
return { ok: false, status: 401, message: "Unauthorized" };
|
|
32
59
|
}
|
|
33
60
|
const supplied = value.slice(AUTH_SCHEME.length);
|
|
34
|
-
if (supplied
|
|
35
|
-
return { ok:
|
|
61
|
+
if (token && timingSafeStringEqual(supplied, token)) {
|
|
62
|
+
return { ok: true, kind: "gateway_bearer", scopes: [] };
|
|
36
63
|
}
|
|
37
|
-
|
|
64
|
+
const oauthToken = validateOAuthAccessToken(supplied);
|
|
65
|
+
if (oauthToken) {
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
kind: "oauth",
|
|
69
|
+
scopes: oauthToken.scopes,
|
|
70
|
+
clientId: oauthToken.clientId,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (!token) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
status: 503,
|
|
77
|
+
message: "HTTP transport requires LLM_GATEWAY_AUTH_TOKEN",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { ok: false, status: 401, message: "Unauthorized" };
|
|
38
81
|
}
|
|
39
|
-
export function writeAuthFailure(res, result) {
|
|
82
|
+
export function writeAuthFailure(res, result, options = {}) {
|
|
40
83
|
const status = result.status ?? 401;
|
|
84
|
+
let wwwAuthenticate = 'Bearer realm="llm-cli-gateway"';
|
|
85
|
+
if (options.resourceMetadataUrl) {
|
|
86
|
+
wwwAuthenticate += `, resource_metadata="${options.resourceMetadataUrl}"`;
|
|
87
|
+
}
|
|
41
88
|
res.writeHead(status, {
|
|
42
89
|
"content-type": "application/json",
|
|
43
|
-
"www-authenticate":
|
|
90
|
+
"www-authenticate": wwwAuthenticate,
|
|
44
91
|
});
|
|
45
92
|
res.end(JSON.stringify({ error: result.message || "Unauthorized" }));
|
|
46
93
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Logger } from "./logger.js";
|
|
2
|
+
import type { RemoteOAuthConfig } from "./auth.js";
|
|
2
3
|
export interface DatabaseConfig {
|
|
3
4
|
connectionString: string;
|
|
4
5
|
pool: {
|
|
@@ -75,3 +76,4 @@ export interface ProvidersConfig {
|
|
|
75
76
|
}
|
|
76
77
|
export declare function loadProvidersConfig(logger?: Logger): ProvidersConfig;
|
|
77
78
|
export declare function isXaiProviderEnabled(config: ProvidersConfig, env?: NodeJS.ProcessEnv): boolean;
|
|
79
|
+
export declare function loadRemoteOAuthConfig(logger?: Logger, env?: NodeJS.ProcessEnv): RemoteOAuthConfig;
|
package/dist/config.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "path";
|
|
|
4
4
|
import { createRequire } from "module";
|
|
5
5
|
import { z } from "zod/v3";
|
|
6
6
|
import { logWarn, noopLogger } from "./logger.js";
|
|
7
|
+
import { hashSecret, isSecretHash } from "./oauth.js";
|
|
7
8
|
const DatabaseUrlSchema = z
|
|
8
9
|
.string()
|
|
9
10
|
.url()
|
|
@@ -75,6 +76,21 @@ function readPersistenceFile(configPath, logger) {
|
|
|
75
76
|
return { raw: undefined, sourcePath: null };
|
|
76
77
|
}
|
|
77
78
|
}
|
|
79
|
+
function readGatewayTomlFile(configPath, logger, fallbackLabel) {
|
|
80
|
+
if (!existsSync(configPath)) {
|
|
81
|
+
return { parsed: null, sourcePath: null };
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const require = createRequire(import.meta.url);
|
|
85
|
+
const TOML = require("smol-toml");
|
|
86
|
+
const text = readFileSync(configPath, "utf-8");
|
|
87
|
+
return { parsed: TOML.parse(text), sourcePath: configPath };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
logger.error(`Failed to parse gateway config at ${configPath}; using ${fallbackLabel} defaults`, err);
|
|
91
|
+
return { parsed: null, sourcePath: null };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
78
94
|
function applyEnvOverrides(base, logger, sources) {
|
|
79
95
|
const out = { ...base };
|
|
80
96
|
const jobsDbEnv = process.env.LLM_GATEWAY_JOBS_DB;
|
|
@@ -332,3 +348,138 @@ export function isXaiProviderEnabled(config, env = process.env) {
|
|
|
332
348
|
return false;
|
|
333
349
|
return typeof env[keyEnv] === "string" && env[keyEnv].trim().length > 0;
|
|
334
350
|
}
|
|
351
|
+
const OAuthRegistrationPolicySchema = z.enum(["static_clients", "shared_secret", "open_dev"]);
|
|
352
|
+
const OAuthClientSchema = z
|
|
353
|
+
.object({
|
|
354
|
+
client_id: z.string().min(1),
|
|
355
|
+
client_secret_hash: z.string().optional(),
|
|
356
|
+
allowed_redirect_uris: z.array(z.string().url()).default([]),
|
|
357
|
+
scopes: z.array(z.string().min(1)).default(["mcp"]),
|
|
358
|
+
})
|
|
359
|
+
.strict();
|
|
360
|
+
const OAuthSharedSecretSchema = z
|
|
361
|
+
.object({
|
|
362
|
+
enabled: z.boolean().default(false),
|
|
363
|
+
secret_hash: z.string().optional(),
|
|
364
|
+
prompt_label: z.string().min(1).default("Gateway access code"),
|
|
365
|
+
})
|
|
366
|
+
.strict();
|
|
367
|
+
const OAuthConfigSchema = z
|
|
368
|
+
.object({
|
|
369
|
+
enabled: z.boolean().default(false),
|
|
370
|
+
issuer: z.string().min(1).default("auto"),
|
|
371
|
+
require_pkce: z.boolean().default(true),
|
|
372
|
+
allow_plain_pkce: z.boolean().default(false),
|
|
373
|
+
registration_policy: OAuthRegistrationPolicySchema.default("static_clients"),
|
|
374
|
+
allow_public_clients: z.boolean().default(false),
|
|
375
|
+
token_ttl_seconds: z.number().int().positive().default(3600),
|
|
376
|
+
clients: z.array(OAuthClientSchema).default([]),
|
|
377
|
+
shared_secret: OAuthSharedSecretSchema.optional(),
|
|
378
|
+
})
|
|
379
|
+
.strict();
|
|
380
|
+
function disabledOAuthConfig(sourcePath = null, envOverrides = []) {
|
|
381
|
+
return {
|
|
382
|
+
enabled: false,
|
|
383
|
+
issuer: "auto",
|
|
384
|
+
requirePkce: true,
|
|
385
|
+
allowPlainPkce: false,
|
|
386
|
+
registrationPolicy: "static_clients",
|
|
387
|
+
allowPublicClients: false,
|
|
388
|
+
tokenTtlSeconds: 3600,
|
|
389
|
+
clients: [],
|
|
390
|
+
sharedSecret: null,
|
|
391
|
+
sources: { configFile: sourcePath, envOverrides },
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function isSafeRedirectUri(uri) {
|
|
395
|
+
return isHttpsOrLoopbackUrl(uri);
|
|
396
|
+
}
|
|
397
|
+
export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
|
|
398
|
+
const configPath = defaultGatewayConfigPath();
|
|
399
|
+
const { parsed: configFile, sourcePath } = readGatewayTomlFile(configPath, logger, "OAuth");
|
|
400
|
+
const rawHttp = configFile?.http ?? {};
|
|
401
|
+
const rawOAuth = rawHttp.oauth ?? {};
|
|
402
|
+
const envOverrides = [];
|
|
403
|
+
const merged = { ...rawOAuth };
|
|
404
|
+
if (env.LLM_GATEWAY_OAUTH_ENABLED !== undefined) {
|
|
405
|
+
merged.enabled = env.LLM_GATEWAY_OAUTH_ENABLED === "1";
|
|
406
|
+
envOverrides.push("LLM_GATEWAY_OAUTH_ENABLED");
|
|
407
|
+
}
|
|
408
|
+
if (env.LLM_GATEWAY_OAUTH_REGISTRATION_SECRET || env.LLM_GATEWAY_OAUTH_SHARED_SECRET) {
|
|
409
|
+
const rawSecret = env.LLM_GATEWAY_OAUTH_REGISTRATION_SECRET || env.LLM_GATEWAY_OAUTH_SHARED_SECRET;
|
|
410
|
+
merged.registration_policy = "shared_secret";
|
|
411
|
+
merged.shared_secret = {
|
|
412
|
+
enabled: true,
|
|
413
|
+
secret_hash: rawSecret ? hashSecret(rawSecret) : undefined,
|
|
414
|
+
prompt_label: "Gateway access code",
|
|
415
|
+
};
|
|
416
|
+
envOverrides.push(env.LLM_GATEWAY_OAUTH_REGISTRATION_SECRET
|
|
417
|
+
? "LLM_GATEWAY_OAUTH_REGISTRATION_SECRET"
|
|
418
|
+
: "LLM_GATEWAY_OAUTH_SHARED_SECRET");
|
|
419
|
+
}
|
|
420
|
+
const parsed = OAuthConfigSchema.safeParse(merged);
|
|
421
|
+
if (!parsed.success) {
|
|
422
|
+
logWarn(logger, "Invalid [http.oauth] config; remote OAuth disabled", {
|
|
423
|
+
error: parsed.error.message,
|
|
424
|
+
});
|
|
425
|
+
return disabledOAuthConfig(sourcePath, envOverrides);
|
|
426
|
+
}
|
|
427
|
+
const data = parsed.data;
|
|
428
|
+
if (data.issuer !== "auto" && !isHttpsOrLoopbackUrl(data.issuer)) {
|
|
429
|
+
logWarn(logger, "Invalid [http.oauth].issuer; remote OAuth disabled");
|
|
430
|
+
return disabledOAuthConfig(sourcePath, envOverrides);
|
|
431
|
+
}
|
|
432
|
+
for (const client of data.clients) {
|
|
433
|
+
if (!data.allow_public_clients && !client.client_secret_hash) {
|
|
434
|
+
logWarn(logger, "OAuth client secret hash is required when public clients are disabled", {
|
|
435
|
+
client_id: client.client_id,
|
|
436
|
+
});
|
|
437
|
+
return disabledOAuthConfig(sourcePath, envOverrides);
|
|
438
|
+
}
|
|
439
|
+
if (client.client_secret_hash && !isSecretHash(client.client_secret_hash)) {
|
|
440
|
+
logWarn(logger, "Invalid OAuth client secret hash; remote OAuth disabled", {
|
|
441
|
+
client_id: client.client_id,
|
|
442
|
+
});
|
|
443
|
+
return disabledOAuthConfig(sourcePath, envOverrides);
|
|
444
|
+
}
|
|
445
|
+
if (client.allowed_redirect_uris.length === 0 ||
|
|
446
|
+
client.allowed_redirect_uris.some(uri => !isSafeRedirectUri(uri))) {
|
|
447
|
+
logWarn(logger, "Invalid OAuth client redirect URI; remote OAuth disabled", {
|
|
448
|
+
client_id: client.client_id,
|
|
449
|
+
});
|
|
450
|
+
return disabledOAuthConfig(sourcePath, envOverrides);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (data.shared_secret?.enabled) {
|
|
454
|
+
if (!data.shared_secret.secret_hash || !isSecretHash(data.shared_secret.secret_hash)) {
|
|
455
|
+
logWarn(logger, "Invalid [http.oauth.shared_secret] secret_hash; remote OAuth disabled");
|
|
456
|
+
return disabledOAuthConfig(sourcePath, envOverrides);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (data.registration_policy === "open_dev" && env.LLM_GATEWAY_OAUTH_OPEN_DEV !== "1") {
|
|
460
|
+
logWarn(logger, "[http.oauth].registration_policy='open_dev' is intended for localhost/dev only");
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
enabled: data.enabled,
|
|
464
|
+
issuer: data.issuer,
|
|
465
|
+
requirePkce: data.require_pkce,
|
|
466
|
+
allowPlainPkce: data.allow_plain_pkce,
|
|
467
|
+
registrationPolicy: data.registration_policy,
|
|
468
|
+
allowPublicClients: data.allow_public_clients,
|
|
469
|
+
tokenTtlSeconds: data.token_ttl_seconds,
|
|
470
|
+
clients: data.clients.map(client => ({
|
|
471
|
+
clientId: client.client_id,
|
|
472
|
+
clientSecretHash: client.client_secret_hash ?? null,
|
|
473
|
+
allowedRedirectUris: client.allowed_redirect_uris,
|
|
474
|
+
scopes: client.scopes,
|
|
475
|
+
})),
|
|
476
|
+
sharedSecret: data.shared_secret
|
|
477
|
+
? {
|
|
478
|
+
enabled: data.shared_secret.enabled,
|
|
479
|
+
secretHash: data.shared_secret.secret_hash ?? null,
|
|
480
|
+
promptLabel: data.shared_secret.prompt_label,
|
|
481
|
+
}
|
|
482
|
+
: null,
|
|
483
|
+
sources: { configFile: sourcePath, envOverrides },
|
|
484
|
+
};
|
|
485
|
+
}
|
package/dist/doctor.d.ts
CHANGED
|
@@ -69,6 +69,21 @@ export interface DoctorReport {
|
|
|
69
69
|
required: boolean;
|
|
70
70
|
token_configured: boolean;
|
|
71
71
|
source: string;
|
|
72
|
+
oauth: {
|
|
73
|
+
enabled: boolean;
|
|
74
|
+
registration_policy: string;
|
|
75
|
+
clients_configured: number;
|
|
76
|
+
shared_secret_enabled: boolean;
|
|
77
|
+
pkce_required: boolean;
|
|
78
|
+
issuer: string | null;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
workspaces: {
|
|
82
|
+
enabled: boolean;
|
|
83
|
+
default: string | null;
|
|
84
|
+
repo_count: number;
|
|
85
|
+
allowed_root_count: number;
|
|
86
|
+
gateway_app_dir_is_workspace: boolean;
|
|
72
87
|
};
|
|
73
88
|
providers: Record<"claude" | "codex" | "gemini" | "grok" | "mistral", {
|
|
74
89
|
cli_available: boolean;
|
package/dist/doctor.js
CHANGED
|
@@ -6,7 +6,8 @@ import { loadAuthConfig } from "./auth.js";
|
|
|
6
6
|
import { createEndpointExposureReport, redactDiagnosticUrl, } from "./endpoint-exposure.js";
|
|
7
7
|
import { listProviderRuntimeStatuses, } from "./provider-status.js";
|
|
8
8
|
import { CLAUDE_MCP_SERVER_NAMES } from "./claude-mcp-config.js";
|
|
9
|
-
import { loadCacheAwarenessConfig } from "./config.js";
|
|
9
|
+
import { loadCacheAwarenessConfig, loadRemoteOAuthConfig, } from "./config.js";
|
|
10
|
+
import { loadWorkspaceRegistry } from "./workspace-registry.js";
|
|
10
11
|
import { computeGlobalCacheStats } from "./cache-stats.js";
|
|
11
12
|
import { FlightRecorder, resolveFlightRecorderDbPath } from "./flight-recorder.js";
|
|
12
13
|
import { buildUpstreamContractReport } from "./upstream-contracts.js";
|
|
@@ -168,16 +169,7 @@ function chatGPTConnectorUrl(env, rawPublicUrl) {
|
|
|
168
169
|
.find(value => value.startsWith("/") && !value.includes("?") && !value.includes("#"));
|
|
169
170
|
if (!rawPublicUrl || !path)
|
|
170
171
|
return null;
|
|
171
|
-
|
|
172
|
-
const url = new URL(rawPublicUrl);
|
|
173
|
-
url.pathname = path;
|
|
174
|
-
url.search = "";
|
|
175
|
-
url.hash = "";
|
|
176
|
-
return redactDiagnosticUrl(url.toString());
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
172
|
+
return "<redacted>";
|
|
181
173
|
}
|
|
182
174
|
function buildCacheAwarenessReport(opts) {
|
|
183
175
|
const enabled = [];
|
|
@@ -240,6 +232,8 @@ export function createDoctorReport(envOrOptions = process.env) {
|
|
|
240
232
|
: { env: envOrOptions };
|
|
241
233
|
const env = opts.env ?? process.env;
|
|
242
234
|
const auth = loadAuthConfig(env);
|
|
235
|
+
const oauth = loadRemoteOAuthConfig(undefined, env);
|
|
236
|
+
const workspaceRegistry = loadWorkspaceRegistry();
|
|
243
237
|
const transport = defaultTransport(env);
|
|
244
238
|
const rawPublicUrl = env.LLM_GATEWAY_PUBLIC_URL || null;
|
|
245
239
|
const publicUrl = redactDiagnosticUrl(rawPublicUrl);
|
|
@@ -294,6 +288,23 @@ export function createDoctorReport(envOrOptions = process.env) {
|
|
|
294
288
|
required: auth.required,
|
|
295
289
|
token_configured: auth.tokenConfigured,
|
|
296
290
|
source: auth.source,
|
|
291
|
+
oauth: {
|
|
292
|
+
enabled: oauth.enabled,
|
|
293
|
+
registration_policy: oauth.registrationPolicy,
|
|
294
|
+
clients_configured: oauth.clients.length,
|
|
295
|
+
shared_secret_enabled: Boolean(oauth.sharedSecret?.enabled),
|
|
296
|
+
pkce_required: oauth.requirePkce,
|
|
297
|
+
issuer: oauth.issuer === "auto"
|
|
298
|
+
? publicUrl
|
|
299
|
+
: redactDiagnosticUrl(oauth.issuer === "auto" ? null : oauth.issuer),
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
workspaces: {
|
|
303
|
+
enabled: workspaceRegistry.enabled,
|
|
304
|
+
default: workspaceRegistry.defaultAlias,
|
|
305
|
+
repo_count: workspaceRegistry.repos.length,
|
|
306
|
+
allowed_root_count: workspaceRegistry.allowedRoots.length,
|
|
307
|
+
gateway_app_dir_is_workspace: workspaceRegistry.repos.some(repo => repo.path === join(homedir(), ".llm-cli-gateway")),
|
|
297
308
|
},
|
|
298
309
|
providers: {
|
|
299
310
|
claude: doctorProviderStatus(providerStatuses.claude),
|
package/dist/http-transport.js
CHANGED
|
@@ -3,31 +3,42 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
4
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import { authorizeBearerRequest, getRequiredBearerToken, writeAuthFailure } from "./auth.js";
|
|
6
|
+
import { loadRemoteOAuthConfig } from "./config.js";
|
|
7
|
+
import { OAuthServer, oauthBaseUrlFromRequest } from "./oauth.js";
|
|
8
|
+
import { runWithRequestContext } from "./request-context.js";
|
|
6
9
|
const noopLogger = {
|
|
7
10
|
info: (..._args) => { },
|
|
8
11
|
error: (..._args) => { },
|
|
9
12
|
debug: (..._args) => { },
|
|
10
13
|
};
|
|
11
|
-
function
|
|
14
|
+
function firstHeader(value) {
|
|
15
|
+
return Array.isArray(value) ? value[0] : value;
|
|
16
|
+
}
|
|
17
|
+
function readRawBody(req) {
|
|
12
18
|
return new Promise((resolve, reject) => {
|
|
13
19
|
const chunks = [];
|
|
14
20
|
req.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
15
21
|
req.on("error", reject);
|
|
16
22
|
req.on("end", () => {
|
|
17
23
|
if (chunks.length === 0) {
|
|
18
|
-
resolve(
|
|
24
|
+
resolve("");
|
|
19
25
|
return;
|
|
20
26
|
}
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
resolve(JSON.parse(raw));
|
|
24
|
-
}
|
|
25
|
-
catch (error) {
|
|
26
|
-
reject(error);
|
|
27
|
-
}
|
|
27
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
28
28
|
});
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
async function readBody(req) {
|
|
32
|
+
const raw = await readRawBody(req);
|
|
33
|
+
if (!raw)
|
|
34
|
+
return undefined;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
31
42
|
function methodNotAllowed(res) {
|
|
32
43
|
res.writeHead(405, { allow: "GET, POST, DELETE", "content-type": "application/json" });
|
|
33
44
|
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
@@ -36,6 +47,10 @@ function jsonError(res, status, message) {
|
|
|
36
47
|
res.writeHead(status, { "content-type": "application/json" });
|
|
37
48
|
res.end(JSON.stringify({ error: message }));
|
|
38
49
|
}
|
|
50
|
+
function jsonResponse(res, status, body) {
|
|
51
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
52
|
+
res.end(JSON.stringify(body));
|
|
53
|
+
}
|
|
39
54
|
function parseNoAuthPaths(raw, protectedPath) {
|
|
40
55
|
const paths = new Set();
|
|
41
56
|
for (const value of (raw ?? "").split(/[,;\s]+/)) {
|
|
@@ -51,6 +66,25 @@ function parseNoAuthPaths(raw, protectedPath) {
|
|
|
51
66
|
}
|
|
52
67
|
return paths;
|
|
53
68
|
}
|
|
69
|
+
function isLocalHost(host) {
|
|
70
|
+
const hostname = host.split(":")[0]?.toLowerCase() ?? "";
|
|
71
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
72
|
+
}
|
|
73
|
+
function requestBaseUrl(req) {
|
|
74
|
+
const configured = process.env.LLM_GATEWAY_PUBLIC_URL;
|
|
75
|
+
if (configured) {
|
|
76
|
+
try {
|
|
77
|
+
return new URL(configured).origin;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const host = firstHeader(req.headers.host) ?? "127.0.0.1:3333";
|
|
83
|
+
const forwardedProto = firstHeader(req.headers["x-forwarded-proto"]);
|
|
84
|
+
const proto = forwardedProto ??
|
|
85
|
+
(host.startsWith("127.0.0.1") || host.startsWith("localhost") ? "http" : "https");
|
|
86
|
+
return `${proto}://${host}`;
|
|
87
|
+
}
|
|
54
88
|
export async function startHttpGateway(options) {
|
|
55
89
|
const host = options.host ?? process.env.LLM_GATEWAY_HTTP_HOST ?? "127.0.0.1";
|
|
56
90
|
const port = options.port ?? Number(process.env.LLM_GATEWAY_HTTP_PORT ?? 3333);
|
|
@@ -59,6 +93,10 @@ export async function startHttpGateway(options) {
|
|
|
59
93
|
const logger = options.logger ?? noopLogger;
|
|
60
94
|
const sessions = new Map();
|
|
61
95
|
const token = getRequiredBearerToken();
|
|
96
|
+
const oauthConfig = loadRemoteOAuthConfig(logger);
|
|
97
|
+
const oauthServer = oauthConfig.enabled
|
|
98
|
+
? new OAuthServer({ protectedPath: path, config: oauthConfig, logger })
|
|
99
|
+
: null;
|
|
62
100
|
async function closeSession(sessionId) {
|
|
63
101
|
const entry = sessions.get(sessionId);
|
|
64
102
|
if (!entry)
|
|
@@ -89,22 +127,46 @@ export async function startHttpGateway(options) {
|
|
|
89
127
|
const httpServer = createServer(async (req, res) => {
|
|
90
128
|
try {
|
|
91
129
|
const url = new URL(req.url || "/", `http://${req.headers.host || `${host}:${port}`}`);
|
|
130
|
+
const baseUrl = requestBaseUrl(req);
|
|
131
|
+
const oauthOrigin = oauthServer ? oauthBaseUrlFromRequest(req, oauthConfig) : null;
|
|
132
|
+
const effectiveOAuthBaseUrl = oauthOrigin ?? baseUrl;
|
|
133
|
+
const resourceMetadataUrl = oauthServer && oauthOrigin ? oauthServer.resourceMetadataUrl(oauthOrigin) : undefined;
|
|
92
134
|
if (url.pathname === "/healthz") {
|
|
93
135
|
res.writeHead(200, { "content-type": "application/json" });
|
|
94
136
|
res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
|
|
95
137
|
return;
|
|
96
138
|
}
|
|
139
|
+
if (oauthServer) {
|
|
140
|
+
if (oauthServer.isOAuthPath(url.pathname) && !oauthOrigin) {
|
|
141
|
+
jsonError(res, 503, "LLM_GATEWAY_PUBLIC_URL is required for public OAuth issuer metadata");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (await oauthServer.handle({
|
|
145
|
+
req,
|
|
146
|
+
res,
|
|
147
|
+
url,
|
|
148
|
+
baseUrl: effectiveOAuthBaseUrl,
|
|
149
|
+
})) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
97
153
|
const noAuthPath = noAuthPaths.has(url.pathname);
|
|
98
154
|
if (url.pathname !== path && !noAuthPath) {
|
|
99
155
|
jsonError(res, 404, "Not found");
|
|
100
156
|
return;
|
|
101
157
|
}
|
|
158
|
+
let requestContext = { authScopes: [] };
|
|
102
159
|
if (!noAuthPath) {
|
|
103
160
|
const auth = authorizeBearerRequest(req, token);
|
|
104
161
|
if (!auth.ok) {
|
|
105
|
-
writeAuthFailure(res, auth);
|
|
162
|
+
writeAuthFailure(res, auth, resourceMetadataUrl ? { resourceMetadataUrl } : {});
|
|
106
163
|
return;
|
|
107
164
|
}
|
|
165
|
+
requestContext = {
|
|
166
|
+
authKind: auth.kind,
|
|
167
|
+
authScopes: auth.scopes ?? [],
|
|
168
|
+
authClientId: auth.clientId,
|
|
169
|
+
};
|
|
108
170
|
}
|
|
109
171
|
if (req.method !== "GET" && req.method !== "POST" && req.method !== "DELETE") {
|
|
110
172
|
methodNotAllowed(res);
|
|
@@ -129,7 +191,7 @@ export async function startHttpGateway(options) {
|
|
|
129
191
|
return;
|
|
130
192
|
}
|
|
131
193
|
const body = req.method === "POST" ? await readBody(req) : undefined;
|
|
132
|
-
await entry.transport.handleRequest(req, res, body);
|
|
194
|
+
await runWithRequestContext(requestContext, () => entry.transport.handleRequest(req, res, body));
|
|
133
195
|
return;
|
|
134
196
|
}
|
|
135
197
|
if (req.method !== "POST") {
|
|
@@ -146,7 +208,7 @@ export async function startHttpGateway(options) {
|
|
|
146
208
|
return;
|
|
147
209
|
}
|
|
148
210
|
const entry = await createSession();
|
|
149
|
-
await entry.transport.handleRequest(req, res, body);
|
|
211
|
+
await runWithRequestContext(requestContext, () => entry.transport.handleRequest(req, res, body));
|
|
150
212
|
}
|
|
151
213
|
catch (error) {
|
|
152
214
|
logger.error("HTTP transport request failed", error);
|