llm-cli-gateway 2.7.0 → 2.9.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +28 -1
  3. package/dist/acp/client.d.ts +78 -0
  4. package/dist/acp/client.js +201 -0
  5. package/dist/acp/errors.d.ts +63 -0
  6. package/dist/acp/errors.js +139 -0
  7. package/dist/acp/json-rpc-stdio.d.ts +71 -0
  8. package/dist/acp/json-rpc-stdio.js +375 -0
  9. package/dist/acp/process-manager.d.ts +66 -0
  10. package/dist/acp/process-manager.js +364 -0
  11. package/dist/acp/provider-registry.d.ts +24 -0
  12. package/dist/acp/provider-registry.js +82 -0
  13. package/dist/acp/types.d.ts +557 -0
  14. package/dist/acp/types.js +335 -0
  15. package/dist/approval-manager.d.ts +1 -0
  16. package/dist/approval-manager.js +14 -1
  17. package/dist/async-job-manager.d.ts +3 -0
  18. package/dist/async-job-manager.js +56 -16
  19. package/dist/auth.d.ts +4 -0
  20. package/dist/auth.js +16 -0
  21. package/dist/cache-stats.d.ts +1 -0
  22. package/dist/cache-stats.js +19 -11
  23. package/dist/cli-updater.js +5 -2
  24. package/dist/codex-json-parser.d.ts +3 -0
  25. package/dist/codex-json-parser.js +17 -0
  26. package/dist/config.d.ts +30 -0
  27. package/dist/config.js +140 -0
  28. package/dist/flight-recorder.d.ts +7 -1
  29. package/dist/flight-recorder.js +33 -6
  30. package/dist/http-transport.js +21 -18
  31. package/dist/index.js +104 -34
  32. package/dist/job-store.d.ts +4 -0
  33. package/dist/job-store.js +16 -4
  34. package/dist/oauth.d.ts +2 -0
  35. package/dist/oauth.js +90 -8
  36. package/dist/pricing.d.ts +1 -1
  37. package/dist/pricing.js +67 -2
  38. package/dist/provider-tool-capabilities.d.ts +38 -0
  39. package/dist/provider-tool-capabilities.js +142 -0
  40. package/dist/request-context.d.ts +4 -0
  41. package/dist/request-context.js +16 -0
  42. package/dist/request-helpers.d.ts +4 -4
  43. package/dist/request-limits.d.ts +8 -0
  44. package/dist/request-limits.js +49 -0
  45. package/dist/secret-redaction.d.ts +3 -0
  46. package/dist/secret-redaction.js +53 -0
  47. package/dist/session-manager-pg.js +8 -5
  48. package/dist/session-manager.d.ts +1 -0
  49. package/dist/session-manager.js +2 -0
  50. package/dist/upstream-contracts.d.ts +27 -0
  51. package/dist/upstream-contracts.js +131 -0
  52. package/migrations/004_session_owner_principal.sql +10 -0
  53. package/npm-shrinkwrap.json +2 -2
  54. package/package.json +1 -1
@@ -237,10 +237,13 @@ export async function runCliUpgrade(params) {
237
237
  exitCode: result.code,
238
238
  };
239
239
  }
240
+ const UPGRADE_TARGET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
240
241
  function normalizeTarget(target) {
241
242
  const normalized = target.trim();
242
- if (!normalized || normalized.startsWith("-") || /[\u0000-\u001f\u007f\s]/.test(normalized)) {
243
- throw new Error("Upgrade target must be a non-empty package tag or version without whitespace and cannot start with '-'");
243
+ if (!UPGRADE_TARGET_PATTERN.test(normalized)) {
244
+ throw new Error("Upgrade target must be a bare version or dist-tag (letters, digits, '.', '_', '-'; " +
245
+ "1-64 chars; must start alphanumeric). Package specifiers, aliases, URLs, and paths " +
246
+ "(containing ':', '/', or '@') are not allowed.");
244
247
  }
245
248
  return normalized;
246
249
  }
@@ -10,5 +10,8 @@ export interface CodexJsonParseResult {
10
10
  error?: string;
11
11
  threadId?: string;
12
12
  finalMessage?: string;
13
+ sawEvent?: boolean;
13
14
  }
14
15
  export declare function parseCodexJsonStream(stdout: string): CodexJsonParseResult;
16
+ export declare function codexDisplayText(stdout: string): string;
17
+ export declare function codexFrResponse(outputFormat: string | undefined, stdout: string): string;
@@ -13,6 +13,7 @@ export function parseCodexJsonStream(stdout) {
13
13
  if (!parsed || typeof parsed !== "object") {
14
14
  continue;
15
15
  }
16
+ result.sawEvent = true;
16
17
  switch (parsed.type) {
17
18
  case "thread.started":
18
19
  if (typeof parsed.thread_id === "string") {
@@ -85,3 +86,19 @@ export function parseCodexJsonStream(stdout) {
85
86
  }
86
87
  return result;
87
88
  }
89
+ export function codexDisplayText(stdout) {
90
+ const parsed = parseCodexJsonStream(stdout);
91
+ if (parsed.finalMessage !== undefined) {
92
+ return parsed.finalMessage;
93
+ }
94
+ if (parsed.error !== undefined) {
95
+ return parsed.error;
96
+ }
97
+ if (parsed.sawEvent) {
98
+ return "";
99
+ }
100
+ return stdout;
101
+ }
102
+ export function codexFrResponse(outputFormat, stdout) {
103
+ return outputFormat === "json" ? stdout : codexDisplayText(stdout);
104
+ }
package/dist/config.d.ts CHANGED
@@ -76,4 +76,34 @@ export interface ProvidersConfig {
76
76
  }
77
77
  export declare function loadProvidersConfig(logger?: Logger): ProvidersConfig;
78
78
  export declare function isXaiProviderEnabled(config: ProvidersConfig, env?: NodeJS.ProcessEnv): boolean;
79
+ export declare const ACP_TRANSPORTS: readonly ["cli", "acp"];
80
+ export type AcpTransport = (typeof ACP_TRANSPORTS)[number];
81
+ export declare const DEFAULT_ACP_PROCESS_IDLE_TIMEOUT_MS = 600000;
82
+ export declare const DEFAULT_ACP_INITIALIZE_TIMEOUT_MS = 10000;
83
+ export declare const DEFAULT_ACP_SESSION_NEW_TIMEOUT_MS = 10000;
84
+ export declare const DEFAULT_ACP_PROMPT_TIMEOUT_MS = 600000;
85
+ export interface AcpProviderConfig {
86
+ enabled: boolean;
87
+ command: string;
88
+ args: string[];
89
+ runtimeEnabled: boolean;
90
+ isolatedLeaderSocket: boolean;
91
+ }
92
+ export interface AcpConfig {
93
+ enabled: boolean;
94
+ defaultTransport: AcpTransport;
95
+ smokeOnStartup: boolean;
96
+ processIdleTimeoutMs: number;
97
+ initializeTimeoutMs: number;
98
+ sessionNewTimeoutMs: number;
99
+ promptTimeoutMs: number;
100
+ allowWriteHostServices: boolean;
101
+ allowTerminalHostServices: boolean;
102
+ fallbackToCliWhenUnhealthy: boolean;
103
+ providers: Record<string, AcpProviderConfig>;
104
+ sources: {
105
+ configFile: string | null;
106
+ };
107
+ }
108
+ export declare function loadAcpConfig(logger?: Logger): AcpConfig;
79
109
  export declare function loadRemoteOAuthConfig(logger?: Logger, env?: NodeJS.ProcessEnv): RemoteOAuthConfig;
package/dist/config.js CHANGED
@@ -348,6 +348,125 @@ export function isXaiProviderEnabled(config, env = process.env) {
348
348
  return false;
349
349
  return typeof env[keyEnv] === "string" && env[keyEnv].trim().length > 0;
350
350
  }
351
+ export const ACP_TRANSPORTS = ["cli", "acp"];
352
+ export const DEFAULT_ACP_PROCESS_IDLE_TIMEOUT_MS = 600000;
353
+ export const DEFAULT_ACP_INITIALIZE_TIMEOUT_MS = 10000;
354
+ export const DEFAULT_ACP_SESSION_NEW_TIMEOUT_MS = 10000;
355
+ export const DEFAULT_ACP_PROMPT_TIMEOUT_MS = 600000;
356
+ const SHELL_METACHARACTERS = /[\s|&;<>(){}$`"'\\*?[\]~#!\0]/;
357
+ function isSafeExecutable(value) {
358
+ if (value.length === 0)
359
+ return false;
360
+ return !SHELL_METACHARACTERS.test(value);
361
+ }
362
+ const SafeExecutableSchema = z
363
+ .string()
364
+ .min(1)
365
+ .refine(isSafeExecutable, {
366
+ message: "ACP provider command must be a bare executable name or path with no shell metacharacters " +
367
+ "(no spaces, quotes, pipes, redirects, globs, or command substitution); pass arguments via 'args'",
368
+ });
369
+ const SafeArgSchema = z.string();
370
+ const AcpProviderSchema = z
371
+ .object({
372
+ enabled: z.boolean().default(false),
373
+ command: SafeExecutableSchema,
374
+ args: z.array(SafeArgSchema).default([]),
375
+ runtime_enabled: z.boolean().default(false),
376
+ isolated_leader_socket: z.boolean().default(false),
377
+ })
378
+ .strict();
379
+ const AcpConfigSchema = z
380
+ .object({
381
+ enabled: z.boolean().default(false),
382
+ default_transport: z.enum(ACP_TRANSPORTS).default("cli"),
383
+ smoke_on_startup: z.boolean().default(false),
384
+ process_idle_timeout_ms: z
385
+ .number()
386
+ .int()
387
+ .positive()
388
+ .default(DEFAULT_ACP_PROCESS_IDLE_TIMEOUT_MS),
389
+ initialize_timeout_ms: z.number().int().positive().default(DEFAULT_ACP_INITIALIZE_TIMEOUT_MS),
390
+ session_new_timeout_ms: z.number().int().positive().default(DEFAULT_ACP_SESSION_NEW_TIMEOUT_MS),
391
+ prompt_timeout_ms: z.number().int().positive().default(DEFAULT_ACP_PROMPT_TIMEOUT_MS),
392
+ allow_write_host_services: z.boolean().default(false),
393
+ allow_terminal_host_services: z.boolean().default(false),
394
+ fallback_to_cli_when_unhealthy: z.boolean().default(true),
395
+ providers: z.record(z.string(), AcpProviderSchema).default({}),
396
+ })
397
+ .strict();
398
+ function defaultAcpConfig(sourcePath) {
399
+ return {
400
+ enabled: false,
401
+ defaultTransport: "cli",
402
+ smokeOnStartup: false,
403
+ processIdleTimeoutMs: DEFAULT_ACP_PROCESS_IDLE_TIMEOUT_MS,
404
+ initializeTimeoutMs: DEFAULT_ACP_INITIALIZE_TIMEOUT_MS,
405
+ sessionNewTimeoutMs: DEFAULT_ACP_SESSION_NEW_TIMEOUT_MS,
406
+ promptTimeoutMs: DEFAULT_ACP_PROMPT_TIMEOUT_MS,
407
+ allowWriteHostServices: false,
408
+ allowTerminalHostServices: false,
409
+ fallbackToCliWhenUnhealthy: true,
410
+ providers: {},
411
+ sources: { configFile: sourcePath },
412
+ };
413
+ }
414
+ function readAcpFile(configPath, logger) {
415
+ if (!existsSync(configPath)) {
416
+ return { raw: undefined, sourcePath: null };
417
+ }
418
+ try {
419
+ const require = createRequire(import.meta.url);
420
+ const TOML = require("smol-toml");
421
+ const text = readFileSync(configPath, "utf-8");
422
+ const parsed = TOML.parse(text);
423
+ return { raw: parsed?.acp, sourcePath: configPath };
424
+ }
425
+ catch (err) {
426
+ logger.error(`Failed to parse gateway config at ${configPath}; using acp defaults`, err);
427
+ return { raw: undefined, sourcePath: null };
428
+ }
429
+ }
430
+ export function loadAcpConfig(logger = noopLogger) {
431
+ const configPath = defaultGatewayConfigPath();
432
+ const { raw, sourcePath } = readAcpFile(configPath, logger);
433
+ if (raw === undefined) {
434
+ return defaultAcpConfig(sourcePath);
435
+ }
436
+ let parsed;
437
+ try {
438
+ parsed = AcpConfigSchema.parse(raw);
439
+ }
440
+ catch (err) {
441
+ throw new Error(`Invalid [acp] config: ${err instanceof Error ? err.message : String(err)}`, {
442
+ cause: err,
443
+ });
444
+ }
445
+ const providers = {};
446
+ for (const [name, p] of Object.entries(parsed.providers)) {
447
+ providers[name] = {
448
+ enabled: p.enabled,
449
+ command: p.command,
450
+ args: p.args,
451
+ runtimeEnabled: p.runtime_enabled,
452
+ isolatedLeaderSocket: p.isolated_leader_socket,
453
+ };
454
+ }
455
+ return {
456
+ enabled: parsed.enabled,
457
+ defaultTransport: parsed.default_transport,
458
+ smokeOnStartup: parsed.smoke_on_startup,
459
+ processIdleTimeoutMs: parsed.process_idle_timeout_ms,
460
+ initializeTimeoutMs: parsed.initialize_timeout_ms,
461
+ sessionNewTimeoutMs: parsed.session_new_timeout_ms,
462
+ promptTimeoutMs: parsed.prompt_timeout_ms,
463
+ allowWriteHostServices: parsed.allow_write_host_services,
464
+ allowTerminalHostServices: parsed.allow_terminal_host_services,
465
+ fallbackToCliWhenUnhealthy: parsed.fallback_to_cli_when_unhealthy,
466
+ providers,
467
+ sources: { configFile: sourcePath },
468
+ };
469
+ }
351
470
  const OAuthRegistrationPolicySchema = z.enum(["static_clients", "shared_secret", "open_dev"]);
352
471
  const OAuthClientSchema = z
353
472
  .object({
@@ -373,6 +492,8 @@ const OAuthConfigSchema = z
373
492
  registration_policy: OAuthRegistrationPolicySchema.default("static_clients"),
374
493
  allow_public_clients: z.boolean().default(false),
375
494
  token_ttl_seconds: z.number().int().positive().default(3600),
495
+ require_consent: z.boolean().default(false),
496
+ consent_secret_hash: z.string().optional(),
376
497
  clients: z.array(OAuthClientSchema).default([]),
377
498
  shared_secret: OAuthSharedSecretSchema.optional(),
378
499
  })
@@ -386,6 +507,8 @@ function disabledOAuthConfig(sourcePath = null, envOverrides = []) {
386
507
  registrationPolicy: "static_clients",
387
508
  allowPublicClients: false,
388
509
  tokenTtlSeconds: 3600,
510
+ requireConsent: false,
511
+ consentSecretHash: null,
389
512
  clients: [],
390
513
  sharedSecret: null,
391
514
  sources: { configFile: sourcePath, envOverrides },
@@ -417,6 +540,15 @@ export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
417
540
  ? "LLM_GATEWAY_OAUTH_REGISTRATION_SECRET"
418
541
  : "LLM_GATEWAY_OAUTH_SHARED_SECRET");
419
542
  }
543
+ if (env.LLM_GATEWAY_OAUTH_REQUIRE_CONSENT !== undefined) {
544
+ merged.require_consent = env.LLM_GATEWAY_OAUTH_REQUIRE_CONSENT === "1";
545
+ envOverrides.push("LLM_GATEWAY_OAUTH_REQUIRE_CONSENT");
546
+ }
547
+ if (env.LLM_GATEWAY_OAUTH_CONSENT_SECRET) {
548
+ merged.consent_secret_hash = hashSecret(env.LLM_GATEWAY_OAUTH_CONSENT_SECRET);
549
+ merged.require_consent = merged.require_consent ?? true;
550
+ envOverrides.push("LLM_GATEWAY_OAUTH_CONSENT_SECRET");
551
+ }
420
552
  const parsed = OAuthConfigSchema.safeParse(merged);
421
553
  if (!parsed.success) {
422
554
  logWarn(logger, "Invalid [http.oauth] config; remote OAuth disabled", {
@@ -459,6 +591,12 @@ export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
459
591
  if (data.registration_policy === "open_dev" && env.LLM_GATEWAY_OAUTH_OPEN_DEV !== "1") {
460
592
  logWarn(logger, "[http.oauth].registration_policy='open_dev' is intended for localhost/dev only");
461
593
  }
594
+ if (data.require_consent) {
595
+ if (!data.consent_secret_hash || !isSecretHash(data.consent_secret_hash)) {
596
+ logWarn(logger, "[http.oauth].require_consent is set but consent_secret_hash is missing/invalid; remote OAuth disabled");
597
+ return disabledOAuthConfig(sourcePath, envOverrides);
598
+ }
599
+ }
462
600
  return {
463
601
  enabled: data.enabled,
464
602
  issuer: data.issuer,
@@ -467,6 +605,8 @@ export function loadRemoteOAuthConfig(logger = noopLogger, env = process.env) {
467
605
  registrationPolicy: data.registration_policy,
468
606
  allowPublicClients: data.allow_public_clients,
469
607
  tokenTtlSeconds: data.token_ttl_seconds,
608
+ requireConsent: data.require_consent,
609
+ consentSecretHash: data.consent_secret_hash ?? null,
470
610
  clients: data.clients.map(client => ({
471
611
  clientId: client.client_id,
472
612
  clientSecretHash: client.client_secret_hash ?? null,
@@ -11,6 +11,7 @@ export interface FlightLogStart {
11
11
  stablePrefixTokens?: number;
12
12
  cacheControlBlocks?: number;
13
13
  cacheControlTtlSeconds?: number;
14
+ ownerPrincipal?: string | null;
14
15
  }
15
16
  export interface FlightLogResult {
16
17
  response: string;
@@ -39,11 +40,16 @@ export declare class FlightRecorder {
39
40
  private readOnlyDb;
40
41
  private closed;
41
42
  private readonly dbPath;
43
+ private readonly redactEnabled;
42
44
  private insertStartTxn;
43
45
  private updateCompleteTxn;
44
- constructor(dbPath: string);
46
+ constructor(dbPath: string, options?: {
47
+ redactSecrets?: boolean;
48
+ });
45
49
  logStart(entry: FlightLogStart): void;
46
50
  logComplete(correlationId: string, result: FlightLogResult): void;
51
+ private redactStart;
52
+ private redactResult;
47
53
  queryRequests<T = Record<string, unknown>>(sql: string, ...params: unknown[]): T[];
48
54
  flush(): void;
49
55
  close(): void;
@@ -2,6 +2,8 @@ import { chmodSync } from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import { openDatabase, openReadOnly } from "./sqlite-driver.js";
5
+ import { redactSecrets, isRedactionEnabled } from "./secret-redaction.js";
6
+ import { getRequestContext, resolveOwnerPrincipal } from "./request-context.js";
5
7
  const MAX_THINKING_BYTES = 1_000_000;
6
8
  function ensureRequestsCacheColumns(db) {
7
9
  const rows = db.prepare("PRAGMA table_info(requests)").all();
@@ -13,6 +15,13 @@ function ensureRequestsCacheColumns(db) {
13
15
  db.exec("ALTER TABLE requests ADD COLUMN cache_creation_tokens INTEGER");
14
16
  }
15
17
  }
18
+ function ensureRequestsOwnerColumn(db) {
19
+ const rows = db.prepare("PRAGMA table_info(requests)").all();
20
+ const names = new Set(rows.map((row) => (row && typeof row.name === "string" ? row.name : "")));
21
+ if (!names.has("owner_principal")) {
22
+ db.exec("ALTER TABLE requests ADD COLUMN owner_principal TEXT");
23
+ }
24
+ }
16
25
  function ensureStablePrefixColumns(db) {
17
26
  const rows = db.prepare("PRAGMA table_info(requests)").all();
18
27
  const names = new Set(rows.map((row) => (row && typeof row.name === "string" ? row.name : "")));
@@ -87,10 +96,12 @@ export class FlightRecorder {
87
96
  readOnlyDb = null;
88
97
  closed = false;
89
98
  dbPath;
99
+ redactEnabled;
90
100
  insertStartTxn;
91
101
  updateCompleteTxn;
92
- constructor(dbPath) {
102
+ constructor(dbPath, options = {}) {
93
103
  this.dbPath = dbPath;
104
+ this.redactEnabled = options.redactSecrets ?? isRedactionEnabled();
94
105
  this.db = openDatabase(dbPath);
95
106
  this.db.exec("PRAGMA journal_mode = WAL");
96
107
  this.db.exec("PRAGMA foreign_keys = ON");
@@ -113,7 +124,8 @@ export class FlightRecorder {
113
124
  input_tokens INTEGER,
114
125
  output_tokens INTEGER,
115
126
  cache_read_tokens INTEGER,
116
- cache_creation_tokens INTEGER
127
+ cache_creation_tokens INTEGER,
128
+ owner_principal TEXT
117
129
  );
118
130
 
119
131
  CREATE TABLE IF NOT EXISTS gateway_metadata (
@@ -155,6 +167,10 @@ export class FlightRecorder {
155
167
  this.db
156
168
  .prepare("INSERT OR IGNORE INTO _migrations(version, applied_at) VALUES(5, ?)")
157
169
  .run(new Date().toISOString());
170
+ ensureRequestsOwnerColumn(this.db);
171
+ this.db
172
+ .prepare("INSERT OR IGNORE INTO _migrations(version, applied_at) VALUES(6, ?)")
173
+ .run(new Date().toISOString());
158
174
  if (process.platform !== "win32") {
159
175
  try {
160
176
  chmodSync(dbPath, 0o600);
@@ -165,10 +181,10 @@ export class FlightRecorder {
165
181
  const insertRequest = this.db.prepare(`
166
182
  INSERT INTO requests (id, cli, model, prompt, system, session_id, datetime_utc,
167
183
  stable_prefix_hash, stable_prefix_tokens,
168
- cache_control_blocks, cache_control_ttl_seconds)
184
+ cache_control_blocks, cache_control_ttl_seconds, owner_principal)
169
185
  VALUES (@id, @cli, @model, @prompt, @system, @session_id, @datetime_utc,
170
186
  @stable_prefix_hash, @stable_prefix_tokens,
171
- @cache_control_blocks, @cache_control_ttl_seconds)
187
+ @cache_control_blocks, @cache_control_ttl_seconds, @owner_principal)
172
188
  `);
173
189
  const insertMetadata = this.db.prepare(`
174
190
  INSERT INTO gateway_metadata (request_id, async_job_id, status)
@@ -187,6 +203,7 @@ export class FlightRecorder {
187
203
  stable_prefix_tokens: entry.stablePrefixTokens ?? null,
188
204
  cache_control_blocks: entry.cacheControlBlocks ?? null,
189
205
  cache_control_ttl_seconds: entry.cacheControlTtlSeconds ?? null,
206
+ owner_principal: entry.ownerPrincipal ?? resolveOwnerPrincipal(getRequestContext()),
190
207
  });
191
208
  insertMetadata.run({
192
209
  request_id: entry.correlationId,
@@ -244,10 +261,20 @@ export class FlightRecorder {
244
261
  });
245
262
  }
246
263
  logStart(entry) {
247
- this.insertStartTxn(entry);
264
+ this.insertStartTxn(this.redactEnabled ? this.redactStart(entry) : entry);
248
265
  }
249
266
  logComplete(correlationId, result) {
250
- this.updateCompleteTxn(correlationId, result);
267
+ this.updateCompleteTxn(correlationId, this.redactEnabled ? this.redactResult(result) : result);
268
+ }
269
+ redactStart(entry) {
270
+ return {
271
+ ...entry,
272
+ prompt: redactSecrets(entry.prompt),
273
+ system: entry.system ? redactSecrets(entry.system) : entry.system,
274
+ };
275
+ }
276
+ redactResult(result) {
277
+ return { ...result, response: redactSecrets(result.response) };
251
278
  }
252
279
  queryRequests(sql, ...params) {
253
280
  if (this.closed) {
@@ -2,10 +2,11 @@ import { createServer } from "node:http";
2
2
  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
- import { authorizeBearerRequest, getRequiredBearerToken, writeAuthFailure } from "./auth.js";
5
+ import { authorizeBearerRequest, getRequiredBearerToken, resolveTrustedPrincipal, writeAuthFailure, } from "./auth.js";
6
6
  import { loadRemoteOAuthConfig } from "./config.js";
7
7
  import { OAuthServer, oauthBaseUrlFromRequest } from "./oauth.js";
8
8
  import { runWithRequestContext } from "./request-context.js";
9
+ import { readCappedRawBody, maxHttpBodyBytes } from "./request-limits.js";
9
10
  const noopLogger = {
10
11
  info: (..._args) => { },
11
12
  error: (..._args) => { },
@@ -14,22 +15,8 @@ const noopLogger = {
14
15
  function firstHeader(value) {
15
16
  return Array.isArray(value) ? value[0] : value;
16
17
  }
17
- function readRawBody(req) {
18
- return new Promise((resolve, reject) => {
19
- const chunks = [];
20
- req.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
21
- req.on("error", reject);
22
- req.on("end", () => {
23
- if (chunks.length === 0) {
24
- resolve("");
25
- return;
26
- }
27
- resolve(Buffer.concat(chunks).toString("utf8"));
28
- });
29
- });
30
- }
31
18
  async function readBody(req) {
32
- const raw = await readRawBody(req);
19
+ const raw = await readCappedRawBody(req, maxHttpBodyBytes());
33
20
  if (!raw)
34
21
  return undefined;
35
22
  try {
@@ -94,6 +81,13 @@ export async function startHttpGateway(options) {
94
81
  const sessions = new Map();
95
82
  const token = getRequiredBearerToken();
96
83
  const oauthConfig = loadRemoteOAuthConfig(logger);
84
+ if (oauthConfig.enabled &&
85
+ (oauthConfig.allowPublicClients || oauthConfig.registrationPolicy === "open_dev") &&
86
+ !isLocalHost(host)) {
87
+ 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 ` +
88
+ `and front the gateway with an authenticating proxy, or switch to ` +
89
+ `registration_policy=static_clients with confidential client secrets.`);
90
+ }
97
91
  const oauthServer = oauthConfig.enabled
98
92
  ? new OAuthServer({ protectedPath: path, config: oauthConfig, logger })
99
93
  : null;
@@ -155,17 +149,20 @@ export async function startHttpGateway(options) {
155
149
  jsonError(res, 404, "Not found");
156
150
  return;
157
151
  }
158
- let requestContext = { authScopes: [] };
152
+ let requestContext = { authScopes: [], transport: "http" };
159
153
  if (!noAuthPath) {
160
154
  const auth = authorizeBearerRequest(req, token);
161
155
  if (!auth.ok) {
162
156
  writeAuthFailure(res, auth, resourceMetadataUrl ? { resourceMetadataUrl } : {});
163
157
  return;
164
158
  }
159
+ const trustedPrincipal = resolveTrustedPrincipal(req, auth);
165
160
  requestContext = {
161
+ transport: "http",
166
162
  authKind: auth.kind,
167
163
  authScopes: auth.scopes ?? [],
168
164
  authClientId: auth.clientId,
165
+ authPrincipal: trustedPrincipal ?? auth.clientId,
169
166
  };
170
167
  }
171
168
  if (req.method !== "GET" && req.method !== "POST" && req.method !== "DELETE") {
@@ -213,7 +210,13 @@ export async function startHttpGateway(options) {
213
210
  catch (error) {
214
211
  logger.error("HTTP transport request failed", error);
215
212
  if (!res.headersSent) {
216
- jsonError(res, 500, "Internal server error");
213
+ const statusCode = error?.statusCode;
214
+ if (statusCode === 413) {
215
+ jsonError(res, 413, "Payload too large");
216
+ }
217
+ else {
218
+ jsonError(res, 500, "Internal server error");
219
+ }
217
220
  }
218
221
  else {
219
222
  res.end();