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 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
- npm install -g @google/gemini-cli
240
- # Or: https://github.com/google-gemini/gemini-cli
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 Gemini CLI request with session support.
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): Gemini approval mode (`default|auto_edit|yolo|plan`) in legacy mode
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
- - `mcpServers` (string[], optional): Allowed Gemini MCP server names
491
- - `allowedTools` (string[], optional): Restrict Gemini tools to this allow-list
492
- - `includeDirs` (string[], optional): Additional workspace directories for Gemini
493
- - `outputFormat` (string, optional): `text` (default), `json` (`-o json`), or `stream-json` (`-o stream-json`, NDJSON with usage extraction)
494
- - `sandbox` (boolean, optional): Run Gemini in sandbox mode (`-s`)
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: `npm install -g @google/gemini-cli@<target>`
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 gemini
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 gemini)
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`, `gemini`, `grok`, `vibe`). |
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 === "mistral" ? "vibe" : 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): void;
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 !== token) {
35
- return { ok: false, status: 401, message: "Unauthorized" };
61
+ if (token && timingSafeStringEqual(supplied, token)) {
62
+ return { ok: true, kind: "gateway_bearer", scopes: [] };
36
63
  }
37
- return { ok: true };
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": 'Bearer realm="llm-cli-gateway"',
90
+ "www-authenticate": wwwAuthenticate,
44
91
  });
45
92
  res.end(JSON.stringify({ error: result.message || "Unauthorized" }));
46
93
  }
@@ -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 NPM_PACKAGES = {
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
- const packageName = cli === "codex" ? NPM_PACKAGES.codex : NPM_PACKAGES.gemini;
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", `${packageName}@${normalizedTarget}`],
114
+ args: ["install", "-g", `${CODEX_NPM_PACKAGE}@${normalizedTarget}`],
105
115
  strategy: "npm-global-install",
106
116
  requiresNetwork: true,
107
- note: cli === "codex"
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: cli,
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 result = await executeCli(cli, args, { timeout: 15_000 });
202
+ const command = providerCommandName(cli);
203
+ const result = await executeCli(command, args, { timeout: 15_000 });
195
204
  return {
196
205
  cli,
197
- command: cli,
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
+ }