llm-cli-gateway 2.4.0 → 2.6.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 +45 -0
- package/README.md +18 -18
- package/dist/async-job-manager.d.ts +2 -0
- package/dist/async-job-manager.js +43 -3
- package/dist/auth.d.ts +44 -1
- package/dist/auth.js +60 -13
- package/dist/cli-updater.js +22 -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/executor.d.ts +1 -0
- package/dist/executor.js +7 -0
- package/dist/http-transport.js +74 -12
- package/dist/index.d.ts +16 -1
- package/dist/index.js +643 -306
- package/dist/oauth.d.ts +38 -0
- package/dist/oauth.js +441 -0
- package/dist/provider-codegen.d.ts +27 -0
- package/dist/provider-codegen.js +335 -0
- package/dist/provider-login-guidance.js +9 -9
- package/dist/provider-status.js +5 -5
- package/dist/request-context.d.ts +7 -0
- package/dist/request-context.js +8 -0
- package/dist/request-helpers.js +2 -2
- package/dist/upstream-contracts.js +95 -116
- 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,51 @@ All notable changes to the llm-cli-gateway project.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [2.6.0] - 2026-06-12: Gemini provider on Google Antigravity CLI
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Gemini provider now runs through Google Antigravity CLI (`agy`)** instead of
|
|
12
|
+
the Google Gemini CLI. `gemini_request` / `gemini_request_async` spawn `agy`;
|
|
13
|
+
install via `curl -fsSL https://antigravity.google/cli/install.sh | bash`;
|
|
14
|
+
upgrade via `agy update` (explicit version targets unsupported); session resume
|
|
15
|
+
via `--conversation <id>` (`sessionId`) or `--continue` (`resumeLatest`). Models
|
|
16
|
+
pass to `agy --model` (e.g. `gemini-3-pro-preview`, `gemini-2.5-flash`, `pro`,
|
|
17
|
+
`flash`, `latest`).
|
|
18
|
+
- `gemini_request` parameter surface tightened to Antigravity's capabilities:
|
|
19
|
+
`approvalMode` accepts only `default` and `yolo` (`auto_edit`/`plan` are
|
|
20
|
+
rejected); `allowedTools`, `mcpServers`, non-`text` `outputFormat`,
|
|
21
|
+
`policyFiles`, `adminPolicyFiles`, `attachments`, and `skipTrust` are rejected
|
|
22
|
+
with an explanatory error (retained in the schema for caller parity).
|
|
23
|
+
`includeDirs` (`--add-dir`) and `sandbox` (`--sandbox`) remain supported.
|
|
24
|
+
- Customer-facing documentation (README, the llm-cli-gateway.dev site, install
|
|
25
|
+
guide, dev.to tutorial) and the MCP server instructions string updated to match
|
|
26
|
+
the Antigravity-backed behavior. Verified by a four-reviewer cross-LLM evidence
|
|
27
|
+
gate (Codex/Gemini/Grok/Mistral); see
|
|
28
|
+
`docs/reviews/2026-06-12-customer-docs-antigravity.*`.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- Reply text is mirrored into MCP `structuredContent.response` on provider tool
|
|
33
|
+
responses (Issue #1), alongside the unchanged `content[0].text`.
|
|
34
|
+
- Contract-driven code generation for the Grok provider's argv and tool schema
|
|
35
|
+
(`src/provider-codegen.ts`), proven byte-identical to the prior hand-written
|
|
36
|
+
surface by golden/parity tests.
|
|
37
|
+
- Async-job stall telemetry (Issue #21).
|
|
38
|
+
|
|
39
|
+
### Upstream provider maintenance
|
|
40
|
+
|
|
41
|
+
- Grok Build v0.2.38: local binary upgraded from 0.2.33; full `--probe-installed` contract + subcommand drift scan executed (live source fetch performed in the run that produced the referenced report). 40 top-level flags + 23 subcommand paths all clean (`extraVsContract: []`, `missingFromBinary: []` across the board per the snapshot). Refreshed `docs/upstream/snapshots/grok.json` (new help surface hash capturing 0.2.38 agent subcommand surface) and `docs/upstream/reports/2026-06-09-grok.md`. `UPSTREAM_CLI_CONTRACTS.grok` now has 18 conformance fixtures (added `grok-0.2.38-agent-surface` as a dated top-level example); no flag, enum, arity, permission-mode, sandbox, output-format, or resume-behaviour changes to encode in the primary contract. `npm run upstream:contracts` and targeted grok/upstream tests pass. (Cross-LLM reviews from Claude and Codex independently reproduced the diff, commands, and fixture behaviour via their own tool inspections of the sources.)
|
|
42
|
+
|
|
43
|
+
## [2.5.0] - 2026-06-08: Remote connector OAuth and workspaces
|
|
44
|
+
|
|
45
|
+
- Added remote connector OAuth discovery and authorization-code support with
|
|
46
|
+
hash-only static client/shared-secret configuration, copy-once local secret
|
|
47
|
+
commands, and OAuth-first ChatGPT setup guidance.
|
|
48
|
+
- Added workspace registry and workspace creation surfaces so provider requests
|
|
49
|
+
can select registered repo aliases and create local folders/Git repos only
|
|
50
|
+
under configured allowed roots.
|
|
51
|
+
|
|
7
52
|
## [2.4.0] - 2026-06-08: Direct Grok API provider and provider-owned sessions
|
|
8
53
|
|
|
9
54
|
### 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
|
|
|
@@ -233,11 +235,13 @@ npm install -g @openai/codex
|
|
|
233
235
|
codex login
|
|
234
236
|
```
|
|
235
237
|
|
|
236
|
-
### Gemini CLI
|
|
238
|
+
### Gemini (Google Antigravity CLI)
|
|
239
|
+
|
|
240
|
+
The Gemini provider runs through Google Antigravity CLI (`agy`).
|
|
237
241
|
|
|
238
242
|
```bash
|
|
239
|
-
|
|
240
|
-
#
|
|
243
|
+
curl -fsSL https://antigravity.google/cli/install.sh | bash
|
|
244
|
+
# Docs: https://antigravity.google/docs/cli-overview
|
|
241
245
|
```
|
|
242
246
|
|
|
243
247
|
### Grok Build CLI (xAI)
|
|
@@ -475,7 +479,7 @@ Fork an existing Codex session into a new branch (`codex fork <SESSION_ID|--last
|
|
|
475
479
|
|
|
476
480
|
##### `gemini_request`
|
|
477
481
|
|
|
478
|
-
Execute a
|
|
482
|
+
Execute a Google Antigravity CLI (`agy`) request with session support.
|
|
479
483
|
|
|
480
484
|
**Parameters:**
|
|
481
485
|
|
|
@@ -484,18 +488,14 @@ Execute a Gemini CLI request with session support.
|
|
|
484
488
|
- `sessionId` (string, optional): Session ID to resume
|
|
485
489
|
- `resumeLatest` (boolean, optional): Resume the latest session automatically
|
|
486
490
|
- `createNewSession` (boolean, optional): Always create a new session
|
|
487
|
-
- `approvalMode` (string, optional):
|
|
491
|
+
- `approvalMode` (string, optional): Antigravity approval mode in legacy mode. Only `default` (prompted execution) and `yolo` (emits `--dangerously-skip-permissions`) are accepted; `auto_edit` and `plan` are rejected with an error.
|
|
488
492
|
- `approvalStrategy` (string, optional): `"legacy"` (default) or `"mcp_managed"`
|
|
489
493
|
- `approvalPolicy` (string, optional): `"strict"`, `"balanced"`, or `"permissive"`
|
|
490
|
-
- `
|
|
491
|
-
- `
|
|
492
|
-
- `
|
|
493
|
-
- `
|
|
494
|
-
- `
|
|
495
|
-
- `policyFiles` / `adminPolicyFiles` (string[], optional): Policy / admin-policy file paths (one `--policy`/`--admin-policy` per file; paths must exist)
|
|
496
|
-
- `attachments` (string[], optional): Absolute file paths prepended as `@<path>` tokens to the prompt
|
|
497
|
-
- `skipTrust` (boolean, optional): Emit `--skip-trust` to trust the workspace for this session (required for headless runs in fresh workspaces)
|
|
498
|
-
- `yolo` (boolean, optional): Auto-approve all; equivalent to `approvalMode: "yolo"`. Emits `--yolo` only when `--approval-mode yolo` is not already being emitted (never both)
|
|
494
|
+
- `includeDirs` (string[], optional): Additional workspace directories (passed as `--add-dir`)
|
|
495
|
+
- `sandbox` (boolean, optional): Run Antigravity in sandbox mode (`--sandbox`)
|
|
496
|
+
- `outputFormat` (string, optional): `text` only. Antigravity print mode emits text; `json` and `stream-json` are rejected.
|
|
497
|
+
- `mcpServers`, `allowedTools`, `policyFiles`, `adminPolicyFiles`, `attachments` (string[], optional) and `skipTrust` (boolean, optional): **Unsupported by Antigravity CLI** — non-empty values (or `skipTrust: true`) are rejected with an explanatory error. Retained in the schema for caller parity.
|
|
498
|
+
- `yolo` (boolean, optional): Auto-approve all; equivalent to `approvalMode: "yolo"`. Emits `--dangerously-skip-permissions`
|
|
499
499
|
- `worktree` (boolean|object, optional): Run inside a gateway-owned git worktree (slice λ)
|
|
500
500
|
- `promptParts` (object, optional): Cache-aware structured prompt `{ system?, tools?, context?, task }`; mutually exclusive with `prompt`
|
|
501
501
|
- `optimizePrompt` (boolean, optional): Optimize prompt for token efficiency, default: false
|
|
@@ -1044,7 +1044,7 @@ Plan or run an upgrade for one CLI.
|
|
|
1044
1044
|
- Claude explicit target: `claude install <target>`
|
|
1045
1045
|
- Codex latest: `codex update`
|
|
1046
1046
|
- Codex explicit target: `npm install -g @openai/codex@<target>`
|
|
1047
|
-
- Gemini: `
|
|
1047
|
+
- Gemini latest: `agy update` (Antigravity self-update; explicit version targets are unsupported)
|
|
1048
1048
|
- Grok latest: `grok update`
|
|
1049
1049
|
- Grok explicit target: `grok update --version <target>`
|
|
1050
1050
|
- Mistral (Vibe): dispatches to the detected installer (`pip`/`uv`/`brew`); errors with guidance when none is detected (Vibe ships no self-update command)
|
|
@@ -1234,7 +1234,7 @@ Make sure the CLIs are installed and in your PATH:
|
|
|
1234
1234
|
```bash
|
|
1235
1235
|
which claude
|
|
1236
1236
|
which codex
|
|
1237
|
-
which
|
|
1237
|
+
which agy
|
|
1238
1238
|
```
|
|
1239
1239
|
|
|
1240
1240
|
The gateway extends PATH to include common locations:
|
|
@@ -1251,7 +1251,7 @@ If you encounter permission errors, ensure the CLI tools have proper permissions
|
|
|
1251
1251
|
```bash
|
|
1252
1252
|
chmod +x $(which claude)
|
|
1253
1253
|
chmod +x $(which codex)
|
|
1254
|
-
chmod +x $(which
|
|
1254
|
+
chmod +x $(which agy)
|
|
1255
1255
|
```
|
|
1256
1256
|
|
|
1257
1257
|
### Session Storage Issues
|
|
@@ -1304,7 +1304,7 @@ If you're vetting `llm-cli-gateway` through [Socket](https://socket.dev/npm/pack
|
|
|
1304
1304
|
| Alert | Where | Why it's bounded |
|
|
1305
1305
|
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
1306
1306
|
| **Network access** | `src/http-transport.ts` opens an HTTP MCP transport when started via `npm run start:http`. `src/endpoint-exposure.ts` issues a HEAD probe to verify configured public/tunnel URLs. Socket also flagged `dist/upstream-contracts.js` in v1.17.2 from descriptive text, not a network call. | The transport binds to `127.0.0.1` by default and requires `LLM_GATEWAY_AUTH_TOKEN` to be set. The default stdio MCP entry point (`npm start`) opens no sockets. `src/upstream-contracts.ts` stores provider CLI metadata and imports no HTTP client APIs. |
|
|
1307
|
-
| **Shell access** | `src/executor.ts` uses `child_process.spawn(cmd, args, …)` to invoke the underlying LLM CLIs. | `spawn` is called with an argument array and **never** `shell: true`, so there is no shell interpolation path for caller input. The command name is restricted to an allow-list of known CLI binaries (`claude`, `codex`, `
|
|
1307
|
+
| **Shell access** | `src/executor.ts` uses `child_process.spawn(cmd, args, …)` to invoke the underlying LLM CLIs. | `spawn` is called with an argument array and **never** `shell: true`, so there is no shell interpolation path for caller input. The command name is restricted to an allow-list of known CLI binaries (`claude`, `codex`, `agy`, `grok`, `vibe`). |
|
|
1308
1308
|
| **Uses eval** | None in our source. Transitive: `@modelcontextprotocol/sdk` → `ajv@8` uses `new Function(...)` in `ajv/dist/compile/index.js` to compile JSON Schema validators. | This is ajv's standard codegen path. Only known schemas (defined in our source and the MCP SDK) flow into it; no caller-supplied data ever reaches the compiled function body. |
|
|
1309
1309
|
| **SQLite adapter isolation** | Persistence uses Node's built-in `node:sqlite` module (no native binding, no install scripts) through a single adapter, `src/sqlite-driver.ts`. | `node:sqlite` is touched by exactly one production module (the adapter); every other module talks to SQLite through its typed surface. We never call any `db.pragma()` helper (it does not exist on `node:sqlite`); SQLite setup uses fixed literal `db.exec("PRAGMA ...")` statements. `npm run security:audit` fails the release if production code references `node:sqlite` outside the adapter or reintroduces a `.pragma()` call. |
|
|
1310
1310
|
| **Dependency ownership** | A handful of small transitive packages (e.g. `media-typer` via `@modelcontextprotocol/sdk`) trip Socket's "unstable ownership" or "obfuscated code" heuristics. | These are pinned, well-known micro-deps in the Node ecosystem with no known issues. We pin direct override versions of `content-type` and `type-is` in `package.json#overrides`. As of 2.0.0 the prod graph carries no native module (`better-sqlite3` moved to devDependencies; `node:sqlite` is built into Node), eliminating the entire `prebuild-install`/`tar-fs`/`tar-stream` install-time chain. Our earlier direct dependency on `toml@3.0.0` was replaced with `smol-toml`. |
|
|
@@ -61,10 +61,12 @@ export declare class AsyncJobManager {
|
|
|
61
61
|
private onJobComplete?;
|
|
62
62
|
private jobs;
|
|
63
63
|
private evictionTimer;
|
|
64
|
+
private stallTimer;
|
|
64
65
|
private processMonitor;
|
|
65
66
|
private store;
|
|
66
67
|
private flightRecorder;
|
|
67
68
|
constructor(logger?: Logger, onJobComplete?: ((cli: LlmCli, durationMs: number, success: boolean) => void) | undefined, store?: JobStore | null, flightRecorder?: FlightRecorderLike);
|
|
69
|
+
checkStalledJobs(now?: number): void;
|
|
68
70
|
hasStore(): boolean;
|
|
69
71
|
private emitMetrics;
|
|
70
72
|
private evictCompletedJobs;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
-
import { envWithExtendedPath, getExtendedPath, killProcessGroup, spawnCliProcess, unregisterProcessGroup, } from "./executor.js";
|
|
3
|
-
import { noopLogger } from "./logger.js";
|
|
2
|
+
import { envWithExtendedPath, getExtendedPath, killProcessGroup, providerCommandName, spawnCliProcess, unregisterProcessGroup, } from "./executor.js";
|
|
3
|
+
import { noopLogger, logWarn } from "./logger.js";
|
|
4
4
|
import { ProcessMonitor } from "./process-monitor.js";
|
|
5
5
|
import { computeRequestKey } from "./job-store.js";
|
|
6
6
|
import { NoopFlightRecorder } from "./flight-recorder.js";
|
|
@@ -8,6 +8,8 @@ const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
|
|
|
8
8
|
const JOB_TTL_MS = 60 * 60 * 1000;
|
|
9
9
|
const EVICTION_INTERVAL_MS = 5 * 60 * 1000;
|
|
10
10
|
const OUTPUT_FLUSH_INTERVAL_MS = 1000;
|
|
11
|
+
const STALL_CHECK_INTERVAL_MS = 60 * 1000;
|
|
12
|
+
const STALL_WARNING_MARKS_MS = [5, 10, 15].map(min => min * 60 * 1000);
|
|
11
13
|
function describeProcessLaunchError(cli, error) {
|
|
12
14
|
const code = error.code;
|
|
13
15
|
if (code === "ENOENT") {
|
|
@@ -55,6 +57,7 @@ export class AsyncJobManager {
|
|
|
55
57
|
onJobComplete;
|
|
56
58
|
jobs = new Map();
|
|
57
59
|
evictionTimer = null;
|
|
60
|
+
stallTimer = null;
|
|
58
61
|
processMonitor;
|
|
59
62
|
store;
|
|
60
63
|
flightRecorder;
|
|
@@ -97,6 +100,43 @@ export class AsyncJobManager {
|
|
|
97
100
|
if (this.evictionTimer.unref) {
|
|
98
101
|
this.evictionTimer.unref();
|
|
99
102
|
}
|
|
103
|
+
this.stallTimer = setInterval(() => this.checkStalledJobs(), STALL_CHECK_INTERVAL_MS);
|
|
104
|
+
if (this.stallTimer.unref) {
|
|
105
|
+
this.stallTimer.unref();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
checkStalledJobs(now = Date.now()) {
|
|
109
|
+
for (const job of this.jobs.values()) {
|
|
110
|
+
if (job.status !== "running")
|
|
111
|
+
continue;
|
|
112
|
+
if (Buffer.byteLength(job.stdout) > 0) {
|
|
113
|
+
job.stallWarnIndex = STALL_WARNING_MARKS_MS.length;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const idx = job.stallWarnIndex ?? 0;
|
|
117
|
+
if (idx >= STALL_WARNING_MARKS_MS.length)
|
|
118
|
+
continue;
|
|
119
|
+
const elapsedMs = now - new Date(job.startedAt).getTime();
|
|
120
|
+
if (elapsedMs < STALL_WARNING_MARKS_MS[idx])
|
|
121
|
+
continue;
|
|
122
|
+
let newIdx = idx;
|
|
123
|
+
while (newIdx < STALL_WARNING_MARKS_MS.length &&
|
|
124
|
+
elapsedMs >= STALL_WARNING_MARKS_MS[newIdx]) {
|
|
125
|
+
newIdx++;
|
|
126
|
+
}
|
|
127
|
+
job.stallWarnIndex = newIdx;
|
|
128
|
+
const crossedMarkMin = Math.round(STALL_WARNING_MARKS_MS[newIdx - 1] / 60000);
|
|
129
|
+
logWarn(this.logger, `Async job ${job.id} (${job.cli}) has produced no stdout after ~${crossedMarkMin}min — possible silent stall (issue #21)`, {
|
|
130
|
+
jobId: job.id,
|
|
131
|
+
cli: job.cli,
|
|
132
|
+
correlationId: job.correlationId,
|
|
133
|
+
elapsedMs,
|
|
134
|
+
stdoutBytes: 0,
|
|
135
|
+
stderrBytes: Buffer.byteLength(job.stderr),
|
|
136
|
+
model: job.flightRecorderEntry?.model,
|
|
137
|
+
promptLength: job.flightRecorderEntry?.prompt?.length,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
100
140
|
}
|
|
101
141
|
hasStore() {
|
|
102
142
|
return this.store !== null;
|
|
@@ -399,7 +439,7 @@ export class AsyncJobManager {
|
|
|
399
439
|
}
|
|
400
440
|
const id = randomUUID();
|
|
401
441
|
const startedAt = new Date().toISOString();
|
|
402
|
-
const command = cli
|
|
442
|
+
const command = providerCommandName(cli);
|
|
403
443
|
const baseEnv = envWithExtendedPath(process.env, getExtendedPath());
|
|
404
444
|
const child = spawnCliProcess(command, args, {
|
|
405
445
|
cwd,
|
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/cli-updater.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { executeCli } from "./executor.js";
|
|
2
|
+
import { executeCli, providerCommandName } from "./executor.js";
|
|
3
3
|
import { getProviderRuntimeStatus } from "./provider-status.js";
|
|
4
4
|
const MISTRAL_VIBE_PACKAGE = "mistral-vibe";
|
|
5
5
|
const LEGACY_VIBE_PACKAGE = "vibe-cli";
|
|
@@ -35,10 +35,7 @@ const VERSION_ARGS = {
|
|
|
35
35
|
grok: ["--version"],
|
|
36
36
|
mistral: ["--version"],
|
|
37
37
|
};
|
|
38
|
-
const
|
|
39
|
-
codex: "@openai/codex",
|
|
40
|
-
gemini: "@google/gemini-cli",
|
|
41
|
-
};
|
|
38
|
+
const CODEX_NPM_PACKAGE = "@openai/codex";
|
|
42
39
|
export function buildCliUpgradePlan(cli, target = "latest", detectMistral = detectMistralInstallMethod) {
|
|
43
40
|
const normalizedTarget = normalizeTarget(target);
|
|
44
41
|
if (cli === "mistral") {
|
|
@@ -96,17 +93,28 @@ export function buildCliUpgradePlan(cli, target = "latest", detectMistral = dete
|
|
|
96
93
|
requiresNetwork: true,
|
|
97
94
|
};
|
|
98
95
|
}
|
|
99
|
-
|
|
96
|
+
if (cli === "gemini") {
|
|
97
|
+
if (normalizedTarget !== "latest") {
|
|
98
|
+
throw new Error("Antigravity CLI upgrades support only the 'latest' target via 'agy update'.");
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
cli,
|
|
102
|
+
target: normalizedTarget,
|
|
103
|
+
command: "agy",
|
|
104
|
+
args: ["update"],
|
|
105
|
+
strategy: "self-update",
|
|
106
|
+
requiresNetwork: true,
|
|
107
|
+
note: "Gemini provider requests now run through Google Antigravity CLI (`agy`).",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
100
110
|
return {
|
|
101
111
|
cli,
|
|
102
112
|
target: normalizedTarget,
|
|
103
113
|
command: "npm",
|
|
104
|
-
args: ["install", "-g", `${
|
|
114
|
+
args: ["install", "-g", `${CODEX_NPM_PACKAGE}@${normalizedTarget}`],
|
|
105
115
|
strategy: "npm-global-install",
|
|
106
116
|
requiresNetwork: true,
|
|
107
|
-
note:
|
|
108
|
-
? "Explicit Codex targets use the documented npm package path; latest can use 'codex update'."
|
|
109
|
-
: "Gemini CLI does not expose a self-update command in the gateway-supported CLI surface, so upgrades use npm.",
|
|
117
|
+
note: "Explicit Codex targets use the documented npm package path; latest can use 'codex update'.",
|
|
110
118
|
};
|
|
111
119
|
}
|
|
112
120
|
export async function getCliVersion(cli) {
|
|
@@ -115,7 +123,7 @@ export async function getCliVersion(cli) {
|
|
|
115
123
|
const status = getProviderRuntimeStatus(cli);
|
|
116
124
|
return {
|
|
117
125
|
cli,
|
|
118
|
-
command:
|
|
126
|
+
command: status.command,
|
|
119
127
|
args,
|
|
120
128
|
installed: status.installed,
|
|
121
129
|
version: status.version || undefined,
|
|
@@ -191,10 +199,11 @@ function buildMistralUpgradePlan(normalizedTarget, detectMistral) {
|
|
|
191
199
|
}
|
|
192
200
|
async function fallbackCliVersion(cli, args) {
|
|
193
201
|
try {
|
|
194
|
-
const
|
|
202
|
+
const command = providerCommandName(cli);
|
|
203
|
+
const result = await executeCli(command, args, { timeout: 15_000 });
|
|
195
204
|
return {
|
|
196
205
|
cli,
|
|
197
|
-
command
|
|
206
|
+
command,
|
|
198
207
|
args,
|
|
199
208
|
installed: true,
|
|
200
209
|
version: extractVersion(result.stdout, result.stderr),
|
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
|
+
}
|