llm-cli-gateway 2.3.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 +79 -9
- package/README.md +3 -1
- package/dist/auth.d.ts +44 -1
- package/dist/auth.js +60 -13
- package/dist/config.d.ts +19 -0
- package/dist/config.js +235 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +22 -11
- package/dist/executor.js +17 -21
- package/dist/flight-recorder.d.ts +2 -1
- package/dist/http-transport.js +74 -12
- package/dist/index.d.ts +42 -7
- package/dist/index.js +1161 -82
- package/dist/metrics.d.ts +3 -3
- package/dist/metrics.js +8 -8
- 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/request-helpers.d.ts +8 -8
- package/dist/resources.js +56 -7
- package/dist/session-manager-pg.d.ts +6 -6
- package/dist/session-manager-pg.js +1 -0
- package/dist/session-manager.d.ts +16 -12
- package/dist/session-manager.js +4 -1
- package/dist/upstream-contracts.d.ts +84 -0
- package/dist/upstream-contracts.js +714 -6
- package/dist/workspace-registry.d.ts +63 -0
- package/dist/workspace-registry.js +417 -0
- package/dist/xai-api-provider.d.ts +43 -0
- package/dist/xai-api-provider.js +191 -0
- package/migrations/001_initial_schema.sql +65 -0
- package/migrations/002_session_ids_as_text.sql +26 -0
- package/migrations/003_provider_type_sessions.sql +20 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/setup/status.schema.json +42 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,76 @@ 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
|
+
|
|
16
|
+
## [2.4.0] - 2026-06-08: Direct Grok API provider and provider-owned sessions
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Direct xAI Grok API provider support:
|
|
21
|
+
- new `ProviderType` split so stored sessions, metrics, flight-recorder rows,
|
|
22
|
+
and migrations can represent the non-CLI `grok-api` provider alongside the
|
|
23
|
+
existing CLI providers;
|
|
24
|
+
- `[providers.xai]` config loading with API-key env indirection and provider
|
|
25
|
+
enablement gating;
|
|
26
|
+
- `grok_api_request`, registered only when xAI API config and credentials are
|
|
27
|
+
present, backed by the xAI Responses API;
|
|
28
|
+
- xAI response parsing, retry/circuit-breaker handling, usage/cost metadata,
|
|
29
|
+
and `previous_response_id` session metadata;
|
|
30
|
+
- focused migration, session-manager, provider-config, and Grok API tests.
|
|
31
|
+
- Provider subcommand contract resources and tooling:
|
|
32
|
+
- provider subcommand catalog/detail resource generation;
|
|
33
|
+
- `provider_subcommands_list`, `provider_subcommand_contract`, and
|
|
34
|
+
`provider_subcommand_drift` surfaces exercised through MCP Inspector.
|
|
35
|
+
- Host auto-upgrade operations:
|
|
36
|
+
- `scripts/host-upgrade.sh` for staged, atomic npm-based host upgrades with
|
|
37
|
+
rollback support;
|
|
38
|
+
- user systemd service/timer units for scheduled gateway auto-upgrade checks.
|
|
39
|
+
- Direct Grok API provider design draft documenting the follow-on async-runner
|
|
40
|
+
and capability-table design work.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Provider-owned stored session enforcement now rejects cross-provider reuse for
|
|
45
|
+
all request handlers, including `claude_request`, `codex_request`,
|
|
46
|
+
`gemini_request`, `grok_request`, `mistral_request`, their async variants,
|
|
47
|
+
`codex_fork_session`, and `grok_api_request`.
|
|
48
|
+
- `sessions://all` now reports active sessions across all provider types,
|
|
49
|
+
including `grok-api`.
|
|
50
|
+
- MCP resource URI schemes now use standards-valid hyphenated forms:
|
|
51
|
+
`cache-state://...` and `provider-subcommands://...`. MCP Inspector exposed
|
|
52
|
+
the previous underscore schemes as invalid URL schemes for standard MCP
|
|
53
|
+
clients. Legacy direct `provider_subcommands://...` reads remain accepted for
|
|
54
|
+
internal compatibility tests/callers, but advertised resources now use only
|
|
55
|
+
valid URI schemes.
|
|
56
|
+
- `src/executor.ts` avoids pidless child-process kill attempts.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- GitHub Actions pins were refreshed to current pinned action SHAs.
|
|
61
|
+
- Provider subcommand support remains CLI-only where appropriate; the direct
|
|
62
|
+
API provider is excluded from spawnable-CLI contract paths.
|
|
63
|
+
|
|
64
|
+
### Verification
|
|
65
|
+
|
|
66
|
+
- Dirty-tree stack split and release evidence recorded in
|
|
67
|
+
`docs/reviews/dirty-tree-stack-split-verification-2026-06-08.md`.
|
|
68
|
+
- MCP Inspector smoke covered tools/list, resources/list, read-only tool calls,
|
|
69
|
+
session lifecycle, direct xAI API registration through a loopback mock, and
|
|
70
|
+
exhaustive advertised-resource reads.
|
|
71
|
+
- Multi-LLM review completed with Claude, Codex, Gemini, Grok, and Mistral
|
|
72
|
+
approvals for the main stack and the Inspector-discovered URI-scheme fix.
|
|
73
|
+
- Final merged `master` verification before mirror push:
|
|
74
|
+
`npm run check` passed, including build, lint, format check, 67 test files /
|
|
75
|
+
1124 tests, and the release security audit.
|
|
76
|
+
|
|
7
77
|
## [2.3.0] - 2026-06-08: MCP tool annotations and client safety hints
|
|
8
78
|
|
|
9
79
|
### Added
|
|
@@ -1512,7 +1582,7 @@ boundary bypass); all are addressed in the two follow-up fix commits.
|
|
|
1512
1582
|
Closes the two telemetry gaps that v1.6.0 explicitly deferred: async-path
|
|
1513
1583
|
flight-recorder integration and Codex parser support for the actual
|
|
1514
1584
|
`cached_input_tokens` field the current Codex CLI emits. Both ship
|
|
1515
|
-
together because they jointly close out `
|
|
1585
|
+
together because they jointly close out `cache-state://*` completeness
|
|
1516
1586
|
for the async tools and the codex CLI.
|
|
1517
1587
|
|
|
1518
1588
|
### Added — async-path flight recorder writes
|
|
@@ -1552,9 +1622,9 @@ stderr, exitCode }> }` so the manager constructor can write FR
|
|
|
1552
1622
|
`{ count: 0, orphaned: [] }` (in-process state can't be orphaned).
|
|
1553
1623
|
Breaking change to the `JobStore` interface; the `PostgresJobStore`
|
|
1554
1624
|
stub was updated to match (the impl is still not yet shipped).
|
|
1555
|
-
- `
|
|
1556
|
-
`
|
|
1557
|
-
activity. No query changes — `
|
|
1625
|
+
- `cache-state://global`, `cache-state://session/{id}`, and
|
|
1626
|
+
`cache-state://prefix/{hash}` aggregates now include async-job
|
|
1627
|
+
activity. No query changes — `cache-state://*` already didn't filter
|
|
1558
1628
|
on `asyncJobId`, so the new rows participate naturally.
|
|
1559
1629
|
|
|
1560
1630
|
### Fixed — Codex parser accepts current CLI's cache-token field
|
|
@@ -1585,7 +1655,7 @@ distinguishing `errorMessage`. The underlying `jobs` table in JobStore
|
|
|
1585
1655
|
retains the distinct `"canceled"` / `"orphaned"` statuses for
|
|
1586
1656
|
`getJobSnapshot` callers. External consumers of `~/.llm-cli-gateway/
|
|
1587
1657
|
logs.db` that filter `status='failed'` will count cancels and boot-time
|
|
1588
|
-
orphans as errors; `
|
|
1658
|
+
orphans as errors; `cache-state://*` aggregation does not distinguish.
|
|
1589
1659
|
|
|
1590
1660
|
### No config or schema changes
|
|
1591
1661
|
|
|
@@ -1647,7 +1717,7 @@ Pure documentation release; zero source-code changes since 1.6.0.
|
|
|
1647
1717
|
### Changed — 12 SKILL.md files current with v1.6.0
|
|
1648
1718
|
|
|
1649
1719
|
- All 12 skills (7 under `skills/`, 5 under `.agents/skills/`) extended
|
|
1650
|
-
with `promptParts`, `
|
|
1720
|
+
with `promptParts`, `cache-state://` MCP resources, and (where the
|
|
1651
1721
|
skill's centre of gravity is session continuity) the
|
|
1652
1722
|
`cache_ttl_expiring_soon` warning. Depth tiered by skill audience:
|
|
1653
1723
|
multi-llm-orchestration, model-routing, multi-llm-consensus,
|
|
@@ -1754,9 +1824,9 @@ Also includes (beyond cache-awareness):
|
|
|
1754
1824
|
`requests`, plus `idx_requests_stable_hash`. Legacy rows keep NULL.
|
|
1755
1825
|
- **Cache-state MCP resources** (read-only, tokens/hashes/aggregates only —
|
|
1756
1826
|
never raw prompt text):
|
|
1757
|
-
- `
|
|
1758
|
-
- `
|
|
1759
|
-
- `
|
|
1827
|
+
- `cache-state://global` (last 24h aggregates + per-CLI breakdown).
|
|
1828
|
+
- `cache-state://session/{sessionId}` (per-session).
|
|
1829
|
+
- `cache-state://prefix/{hash}` (per-stable-prefix-hash).
|
|
1760
1830
|
- **`session_get.cacheState`** projection: compact hit-rate / hit-count /
|
|
1761
1831
|
cache-token-totals / estimated-savings-USD block, present only when the
|
|
1762
1832
|
session has prior requests. Omitted entirely (not null, not empty) for
|
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
|
|
|
@@ -164,7 +166,7 @@ docker compose -f docker/personal.compose.yml run --rm doctor
|
|
|
164
166
|
|
|
165
167
|
- **SQLite Flight Recorder**: Every request/response logged to `~/.llm-cli-gateway/logs.db` with correlation IDs, token usage, duration, retry counts, and circuit breaker state. Browse with [Datasette](https://datasette.io/): `datasette ~/.llm-cli-gateway/logs.db`
|
|
166
168
|
- **Structured Metadata**: Tool responses include machine-readable `structuredContent` (model, cli, correlationId, sessionId, durationMs, token counts)
|
|
167
|
-
- **Cache observability resources**: `
|
|
169
|
+
- **Cache observability resources**: `cache-state://global`, `cache-state://session/{id}`, and `cache-state://prefix/{hash}` MCP resources return aggregate cache hit/miss/savings — tokens and hashes only, no prompt text. `session_get` includes a `cacheState` block when the session has prior requests.
|
|
168
170
|
|
|
169
171
|
### Cache-aware operation
|
|
170
172
|
|
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: {
|
|
@@ -32,6 +33,7 @@ export interface PersistenceConfigSources {
|
|
|
32
33
|
configFile: string | null;
|
|
33
34
|
envOverrides: string[];
|
|
34
35
|
}
|
|
36
|
+
export declare function defaultGatewayConfigPath(): string;
|
|
35
37
|
export declare function loadPersistenceConfig(logger?: Logger): PersistenceConfig;
|
|
36
38
|
export declare const ANTHROPIC_TTL_SECONDS_VALUES: readonly [300, 3600];
|
|
37
39
|
export type AnthropicTtlSeconds = (typeof ANTHROPIC_TTL_SECONDS_VALUES)[number];
|
|
@@ -58,3 +60,20 @@ export interface CacheAwarenessConfig {
|
|
|
58
60
|
}
|
|
59
61
|
export declare function loadCacheAwarenessConfig(logger?: Logger): CacheAwarenessConfig;
|
|
60
62
|
export declare function minStableTokensForModel(config: CacheAwarenessConfig, modelName: string): number;
|
|
63
|
+
export declare const DEFAULT_XAI_API_KEY_ENV = "XAI_API_KEY";
|
|
64
|
+
export declare const DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1";
|
|
65
|
+
export declare const DEFAULT_XAI_MODEL = "grok-build-0.1";
|
|
66
|
+
export interface XaiProviderConfig {
|
|
67
|
+
apiKeyEnv: string;
|
|
68
|
+
baseUrl: string;
|
|
69
|
+
defaultModel: string;
|
|
70
|
+
}
|
|
71
|
+
export interface ProvidersConfig {
|
|
72
|
+
xai: XaiProviderConfig | null;
|
|
73
|
+
sources: {
|
|
74
|
+
configFile: string | null;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export declare function loadProvidersConfig(logger?: Logger): ProvidersConfig;
|
|
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()
|
|
@@ -56,6 +57,9 @@ const DEFAULT_SQLITE_PATH = path.join(os.homedir(), ".llm-cli-gateway", "logs.db
|
|
|
56
57
|
function defaultPersistenceConfigPath() {
|
|
57
58
|
return (process.env.LLM_GATEWAY_CONFIG ?? path.join(os.homedir(), ".llm-cli-gateway", "config.toml"));
|
|
58
59
|
}
|
|
60
|
+
export function defaultGatewayConfigPath() {
|
|
61
|
+
return defaultPersistenceConfigPath();
|
|
62
|
+
}
|
|
59
63
|
function readPersistenceFile(configPath, logger) {
|
|
60
64
|
if (!existsSync(configPath)) {
|
|
61
65
|
return { raw: undefined, sourcePath: null };
|
|
@@ -72,6 +76,21 @@ function readPersistenceFile(configPath, logger) {
|
|
|
72
76
|
return { raw: undefined, sourcePath: null };
|
|
73
77
|
}
|
|
74
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
|
+
}
|
|
75
94
|
function applyEnvOverrides(base, logger, sources) {
|
|
76
95
|
const out = { ...base };
|
|
77
96
|
const jobsDbEnv = process.env.LLM_GATEWAY_JOBS_DB;
|
|
@@ -248,3 +267,219 @@ export function minStableTokensForModel(config, modelName) {
|
|
|
248
267
|
return table.haiku;
|
|
249
268
|
return table.default;
|
|
250
269
|
}
|
|
270
|
+
export const DEFAULT_XAI_API_KEY_ENV = "XAI_API_KEY";
|
|
271
|
+
export const DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1";
|
|
272
|
+
export const DEFAULT_XAI_MODEL = "grok-build-0.1";
|
|
273
|
+
function isHttpsOrLoopbackUrl(value) {
|
|
274
|
+
try {
|
|
275
|
+
const url = new URL(value);
|
|
276
|
+
if (url.protocol === "https:")
|
|
277
|
+
return true;
|
|
278
|
+
if (url.protocol !== "http:")
|
|
279
|
+
return false;
|
|
280
|
+
return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const XaiProviderSchema = z
|
|
287
|
+
.object({
|
|
288
|
+
api_key_env: z.string().min(1).default(DEFAULT_XAI_API_KEY_ENV),
|
|
289
|
+
base_url: z
|
|
290
|
+
.string()
|
|
291
|
+
.url()
|
|
292
|
+
.refine(isHttpsOrLoopbackUrl, {
|
|
293
|
+
message: "base_url must use https unless it targets localhost/loopback for tests",
|
|
294
|
+
})
|
|
295
|
+
.default(DEFAULT_XAI_BASE_URL),
|
|
296
|
+
default_model: z.string().min(1).default(DEFAULT_XAI_MODEL),
|
|
297
|
+
})
|
|
298
|
+
.strict();
|
|
299
|
+
function readProvidersFile(configPath, logger) {
|
|
300
|
+
if (!existsSync(configPath)) {
|
|
301
|
+
return { raw: undefined, sourcePath: null };
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const require = createRequire(import.meta.url);
|
|
305
|
+
const TOML = require("smol-toml");
|
|
306
|
+
const text = readFileSync(configPath, "utf-8");
|
|
307
|
+
const parsed = TOML.parse(text);
|
|
308
|
+
return { raw: parsed?.providers, sourcePath: configPath };
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
logger.error(`Failed to parse gateway config at ${configPath}; using provider defaults`, err);
|
|
312
|
+
return { raw: undefined, sourcePath: null };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
export function loadProvidersConfig(logger = noopLogger) {
|
|
316
|
+
const configPath = defaultGatewayConfigPath();
|
|
317
|
+
const { raw, sourcePath } = readProvidersFile(configPath, logger);
|
|
318
|
+
const providers = raw ?? {};
|
|
319
|
+
const rawXai = providers.xai;
|
|
320
|
+
if (rawXai === undefined) {
|
|
321
|
+
return {
|
|
322
|
+
xai: null,
|
|
323
|
+
sources: { configFile: sourcePath },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const parsed = XaiProviderSchema.safeParse(rawXai);
|
|
327
|
+
if (!parsed.success) {
|
|
328
|
+
logWarn(logger, "Invalid [providers.xai] config; xAI API provider disabled", {
|
|
329
|
+
error: parsed.error.message,
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
xai: null,
|
|
333
|
+
sources: { configFile: sourcePath },
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
xai: {
|
|
338
|
+
apiKeyEnv: parsed.data.api_key_env,
|
|
339
|
+
baseUrl: parsed.data.base_url,
|
|
340
|
+
defaultModel: parsed.data.default_model,
|
|
341
|
+
},
|
|
342
|
+
sources: { configFile: sourcePath },
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
export function isXaiProviderEnabled(config, env = process.env) {
|
|
346
|
+
const keyEnv = config.xai?.apiKeyEnv;
|
|
347
|
+
if (!keyEnv)
|
|
348
|
+
return false;
|
|
349
|
+
return typeof env[keyEnv] === "string" && env[keyEnv].trim().length > 0;
|
|
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),
|