llm-cli-gateway 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +28 -1
  3. package/dist/acp/client.d.ts +78 -0
  4. package/dist/acp/client.js +201 -0
  5. package/dist/acp/errors.d.ts +63 -0
  6. package/dist/acp/errors.js +139 -0
  7. package/dist/acp/json-rpc-stdio.d.ts +71 -0
  8. package/dist/acp/json-rpc-stdio.js +375 -0
  9. package/dist/acp/process-manager.d.ts +66 -0
  10. package/dist/acp/process-manager.js +364 -0
  11. package/dist/acp/provider-registry.d.ts +24 -0
  12. package/dist/acp/provider-registry.js +82 -0
  13. package/dist/acp/types.d.ts +557 -0
  14. package/dist/acp/types.js +335 -0
  15. package/dist/approval-manager.d.ts +1 -0
  16. package/dist/approval-manager.js +14 -1
  17. package/dist/async-job-manager.d.ts +3 -0
  18. package/dist/async-job-manager.js +56 -16
  19. package/dist/auth.d.ts +4 -0
  20. package/dist/auth.js +16 -0
  21. package/dist/cache-stats.d.ts +1 -0
  22. package/dist/cache-stats.js +19 -11
  23. package/dist/cli-updater.js +5 -2
  24. package/dist/codex-json-parser.d.ts +3 -0
  25. package/dist/codex-json-parser.js +17 -0
  26. package/dist/config.d.ts +30 -0
  27. package/dist/config.js +140 -0
  28. package/dist/flight-recorder.d.ts +7 -1
  29. package/dist/flight-recorder.js +33 -6
  30. package/dist/http-transport.js +21 -18
  31. package/dist/index.js +104 -34
  32. package/dist/job-store.d.ts +4 -0
  33. package/dist/job-store.js +16 -4
  34. package/dist/oauth.d.ts +2 -0
  35. package/dist/oauth.js +90 -8
  36. package/dist/pricing.d.ts +1 -1
  37. package/dist/pricing.js +67 -2
  38. package/dist/provider-tool-capabilities.d.ts +38 -0
  39. package/dist/provider-tool-capabilities.js +142 -0
  40. package/dist/request-context.d.ts +4 -0
  41. package/dist/request-context.js +16 -0
  42. package/dist/request-helpers.d.ts +4 -4
  43. package/dist/request-limits.d.ts +8 -0
  44. package/dist/request-limits.js +49 -0
  45. package/dist/secret-redaction.d.ts +3 -0
  46. package/dist/secret-redaction.js +53 -0
  47. package/dist/session-manager-pg.js +8 -5
  48. package/dist/session-manager.d.ts +1 -0
  49. package/dist/session-manager.js +2 -0
  50. package/dist/upstream-contracts.d.ts +27 -0
  51. package/dist/upstream-contracts.js +131 -0
  52. package/migrations/004_session_owner_principal.sql +10 -0
  53. package/npm-shrinkwrap.json +2 -2
  54. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath } from "url";
9
9
  import { z } from "zod/v3";
10
10
  import { executeCli, killAllProcessGroups, providerCommandName } from "./executor.js";
11
11
  import { parseStreamJson } from "./stream-json-parser.js";
12
- import { parseCodexJsonStream } from "./codex-json-parser.js";
12
+ import { parseCodexJsonStream, codexDisplayText, codexFrResponse } from "./codex-json-parser.js";
13
13
  import { parseGeminiJson, parseGeminiStreamJson } from "./gemini-json-parser.js";
14
14
  import { parseVibeMetaJson } from "./mistral-meta-json-parser.js";
15
15
  import { homedir } from "os";
@@ -25,7 +25,7 @@ import { clearModelRegistryCache, getAvailableCliInfo, getCliInfo, resolveModelA
25
25
  import { getProviderToolCapabilities } from "./provider-tool-capabilities.js";
26
26
  import { AsyncJobManager, } from "./async-job-manager.js";
27
27
  import { createJobStore } from "./job-store.js";
28
- import { ApprovalManager } from "./approval-manager.js";
28
+ import { ApprovalManager, bypassAllowedByOperator, } from "./approval-manager.js";
29
29
  import { checkReviewIntegrity } from "./review-integrity.js";
30
30
  import { buildClaudeMcpConfig, CLAUDE_MCP_SERVER_NAMES, } from "./claude-mcp-config.js";
31
31
  import { resolveGrokSessionArgs, resolveMistralSessionArgs, resolveCodexSessionArgs, sanitizeCliArgValues, prepareMistralRequest as buildMistralCliInvocation, MISTRAL_AGENT_MODES, GATEWAY_SESSION_PREFIX, resolveClaudePermissionFlags, resolveCodexSandboxFlags, CLAUDE_PERMISSION_MODES, GEMINI_APPROVAL_MODES, CODEX_SANDBOX_MODES, CODEX_ASK_FOR_APPROVAL_MODES, CLAUDE_EFFORT_LEVELS, prepareClaudeHighImpactFlags, validateClaudeAgentsMap, prepareCodexHighImpactFlags, prepareCodexForkRequest, CODEX_CONFIG_OVERRIDES_SCHEMA, resolveGeminiSessionPlan, GEMINI_HIGH_IMPACT_PARAMS_SCHEMA, } from "./request-helpers.js";
@@ -34,7 +34,7 @@ import { resolvePromptInput, PromptPartsSchema, assembleClaudeCacheBlocks, } fro
34
34
  import { computeSessionCacheStats, computeTtlRemaining, readPersistedRequest, PERSISTED_REQUEST_DEFAULT_MAX_CHARS, } from "./cache-stats.js";
35
35
  import { getCliVersions, runCliUpgrade } from "./cli-updater.js";
36
36
  import { startHttpGateway } from "./http-transport.js";
37
- import { getRequestContext } from "./request-context.js";
37
+ import { getRequestContext, resolveOwnerPrincipal, principalCanAccess } from "./request-context.js";
38
38
  import { printDoctorJson } from "./doctor.js";
39
39
  import { createWorkspace, describeWorkspace, getWorkspace, loadWorkspaceRegistry, registerExistingWorkspace, resolveWorkspaceForProvider, validatePathInsideWorkspace, } from "./workspace-registry.js";
40
40
  import { generateSecret, hashSecret } from "./oauth.js";
@@ -489,13 +489,10 @@ async function resolveWorkspaceAndWorktreeForRequest(args) {
489
489
  else if (isGatewayAppDirCwd()) {
490
490
  throw new Error("No workspace selected. Configure [workspaces].default or pass a registered workspace alias.");
491
491
  }
492
- if (!workspace && getRequestContext()?.authKind === "oauth") {
493
- throw new Error("Remote OAuth provider requests require a registered workspace alias or [workspaces].default.");
494
- }
495
- if (!workspace &&
496
- (args.workingDir || (args.addDir?.length ?? 0) > 0) &&
497
- !args.runtime.workspaces.allowUnregisteredWorkingDir) {
498
- throw new Error("workingDir/addDir require a registered workspace alias unless [workspaces].allow_unregistered_working_dir is explicitly enabled.");
492
+ const requestContext = getRequestContext();
493
+ const isRemoteTransport = requestContext?.transport === "http" || requestContext?.authKind === "oauth";
494
+ if (!workspace && isRemoteTransport) {
495
+ throw new Error("Remote HTTP provider requests require a registered workspace alias, session workspace, or [workspaces].default.");
499
496
  }
500
497
  if (workspace) {
501
498
  if (args.workingDir) {
@@ -720,7 +717,7 @@ export function extractUsageAndCost(cli, output, outputFormat, ctx) {
720
717
  costUsd: parsed.costUsd ?? undefined,
721
718
  };
722
719
  }
723
- if (cli === "codex" && outputFormat === "json") {
720
+ if (cli === "codex") {
724
721
  const parsed = parseCodexJsonStream(output);
725
722
  if (!parsed.usage) {
726
723
  return {};
@@ -1277,7 +1274,8 @@ export function prepareClaudeRequest(params, runtime = resolveGatewayServerRunti
1277
1274
  args.push("--disallowed-tools", ...params.disallowedTools);
1278
1275
  }
1279
1276
  if (params.approvalStrategy === "mcp_managed") {
1280
- args.push("--permission-mode", "bypassPermissions");
1277
+ const managedMode = bypassAllowedByOperator() ? "bypassPermissions" : "acceptEdits";
1278
+ args.push("--permission-mode", managedMode);
1281
1279
  }
1282
1280
  else {
1283
1281
  const permFlags = resolveClaudePermissionFlags({
@@ -1420,9 +1418,7 @@ export function prepareCodexRequest(params, runtime = resolveGatewayServerRuntim
1420
1418
  if (params.dangerouslyBypassApprovalsAndSandbox) {
1421
1419
  args.push("--dangerously-bypass-approvals-and-sandbox");
1422
1420
  }
1423
- if (params.outputFormat === "json") {
1424
- args.push("--json");
1425
- }
1421
+ args.push("--json");
1426
1422
  args.push("--skip-git-repo-check");
1427
1423
  let highImpactCleanup;
1428
1424
  if (sessionPlan.mode === "new") {
@@ -1544,7 +1540,11 @@ export function prepareGeminiRequest(params, runtime = resolveGatewayServerRunti
1544
1540
  return createApprovalDeniedResponse(params.operation, approvalDecision);
1545
1541
  }
1546
1542
  }
1547
- const effectiveApprovalMode = params.approvalStrategy === "mcp_managed" ? "yolo" : params.approvalMode;
1543
+ const effectiveApprovalMode = params.approvalStrategy === "mcp_managed"
1544
+ ? bypassAllowedByOperator()
1545
+ ? "yolo"
1546
+ : "default"
1547
+ : params.approvalMode;
1548
1548
  const unsupported = (field, detail) => createErrorResponse(params.operation, 1, "", corrId, new Error(`${field} is not supported by Antigravity CLI (agy): ${detail}`));
1549
1549
  if (effectiveApprovalMode &&
1550
1550
  effectiveApprovalMode !== "default" &&
@@ -1651,14 +1651,22 @@ export function prepareGrokRequest(params, runtime = resolveGatewayServerRuntime
1651
1651
  return createApprovalDeniedResponse(params.operation, approvalDecision);
1652
1652
  }
1653
1653
  }
1654
- const effectiveAlwaysApprove = params.approvalStrategy === "mcp_managed" ? true : Boolean(params.alwaysApprove);
1654
+ const managedGrokBypass = params.approvalStrategy === "mcp_managed" && bypassAllowedByOperator();
1655
1655
  const grokContract = UPSTREAM_CLI_CONTRACTS.grok;
1656
1656
  const genParams = params;
1657
1657
  const args = ["-p", effectivePrompt];
1658
1658
  if (resolvedModel)
1659
1659
  args.push("--model", resolvedModel);
1660
1660
  args.push(...buildArgvFromGeneration(grokContract, GROK_GEN_OUTPUT_FORMAT, genParams));
1661
- if (effectiveAlwaysApprove) {
1661
+ if (params.approvalStrategy === "mcp_managed") {
1662
+ if (managedGrokBypass) {
1663
+ args.push("--always-approve");
1664
+ }
1665
+ else {
1666
+ args.push("--permission-mode", "acceptEdits");
1667
+ }
1668
+ }
1669
+ else if (Boolean(params.alwaysApprove)) {
1662
1670
  args.push("--always-approve");
1663
1671
  }
1664
1672
  else if (params.permissionMode) {
@@ -1765,7 +1773,9 @@ export function prepareMistralRequest(params, runtime = resolveGatewayServerRunt
1765
1773
  }
1766
1774
  }
1767
1775
  const effectivePermissionMode = params.approvalStrategy === "mcp_managed"
1768
- ? "auto-approve"
1776
+ ? bypassAllowedByOperator()
1777
+ ? "auto-approve"
1778
+ : "accept-edits"
1769
1779
  : (params.permissionMode ?? "auto-approve");
1770
1780
  const prep = buildMistralCliInvocation({
1771
1781
  prompt: effectivePrompt,
@@ -1816,7 +1826,9 @@ export function buildMistralRetryPrep(params, recoveryModel) {
1816
1826
  resolvedModel: recoveryModel,
1817
1827
  outputFormat: params.outputFormat,
1818
1828
  permissionMode: params.approvalStrategy === "mcp_managed"
1819
- ? "auto-approve"
1829
+ ? bypassAllowedByOperator()
1830
+ ? "auto-approve"
1831
+ : "accept-edits"
1820
1832
  : (params.permissionMode ?? "auto-approve"),
1821
1833
  allowedTools: params.allowedTools,
1822
1834
  disallowedTools: params.disallowedTools,
@@ -1830,6 +1842,9 @@ export function buildMistralRetryPrep(params, recoveryModel) {
1830
1842
  }
1831
1843
  function buildCliResponse(cli, stdout, optimizeResponse, corrId, sessionId, prep, durationMs, resumable, outputFormat, warnings) {
1832
1844
  let finalStdout = stdout;
1845
+ if (cli === "codex" && outputFormat !== "json") {
1846
+ finalStdout = codexDisplayText(stdout);
1847
+ }
1833
1848
  if (optimizeResponse && outputFormat !== "json") {
1834
1849
  const optimized = optimizeResponseText(finalStdout);
1835
1850
  logOptimizationTokens("response", corrId, finalStdout, optimized);
@@ -3619,7 +3634,7 @@ export function createGatewayServer(deps = {}) {
3619
3634
  outputFormat: z
3620
3635
  .enum(["text", "json"])
3621
3636
  .default("text")
3622
- .describe("Codex output format. `json` emits --json (JSONL events) so token usage and cost are parsed and reported in the flight recorder. `text` is the default."),
3637
+ .describe("Codex caller-facing output format. Token/cache usage is recorded in the flight recorder regardless. `text` (default) returns the plain reply; `json` returns the raw `--json` JSONL event stream."),
3623
3638
  outputSchema: z
3624
3639
  .union([z.string(), z.record(z.string(), z.unknown())])
3625
3640
  .optional()
@@ -3779,7 +3794,7 @@ export function createGatewayServer(deps = {}) {
3779
3794
  logger.info(`[${corrId}] codex_request completed successfully in ${durationMs}ms`);
3780
3795
  const codexUsage = extractUsageAndCost("codex", stdout, outputFormat);
3781
3796
  safeFlightComplete(corrId, {
3782
- response: stdout,
3797
+ response: codexFrResponse(outputFormat, stdout),
3783
3798
  durationMs,
3784
3799
  retryCount: 0,
3785
3800
  circuitBreakerState: "closed",
@@ -4622,7 +4637,7 @@ export function createGatewayServer(deps = {}) {
4622
4637
  outputFormat: z
4623
4638
  .enum(["text", "json"])
4624
4639
  .default("text")
4625
- .describe("Codex output format. `json` emits --json (JSONL events) for token usage extraction."),
4640
+ .describe("Codex caller-facing output format. Token/cache usage is recorded in the flight recorder regardless. `text` (default) returns the plain reply; `json` returns the raw `--json` JSONL event stream."),
4626
4641
  outputSchema: z
4627
4642
  .union([z.string(), z.record(z.string(), z.unknown())])
4628
4643
  .optional()
@@ -5160,7 +5175,8 @@ export function createGatewayServer(deps = {}) {
5160
5175
  openWorldHint: false,
5161
5176
  }, async ({ jobId }) => {
5162
5177
  const job = asyncJobManager.getJobSnapshot(jobId);
5163
- if (!job) {
5178
+ const caller = resolveOwnerPrincipal(getRequestContext());
5179
+ if (!job || !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
5164
5180
  return {
5165
5181
  content: [
5166
5182
  {
@@ -5204,7 +5220,8 @@ export function createGatewayServer(deps = {}) {
5204
5220
  openWorldHint: false,
5205
5221
  }, async ({ jobId, maxChars }) => {
5206
5222
  const result = asyncJobManager.getJobResult(jobId, maxChars);
5207
- if (!result) {
5223
+ const caller = resolveOwnerPrincipal(getRequestContext());
5224
+ if (!result || !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
5208
5225
  return {
5209
5226
  content: [
5210
5227
  {
@@ -5224,6 +5241,11 @@ export function createGatewayServer(deps = {}) {
5224
5241
  if (outputFormat === "stream-json" && result.stdout) {
5225
5242
  parsed = parseStreamJson(result.stdout);
5226
5243
  }
5244
+ if (asyncJobManager.getJobCli(jobId) === "codex" &&
5245
+ outputFormat !== "json" &&
5246
+ result.stdout) {
5247
+ result.stdout = codexDisplayText(result.stdout);
5248
+ }
5227
5249
  return {
5228
5250
  content: [
5229
5251
  {
@@ -5256,6 +5278,23 @@ export function createGatewayServer(deps = {}) {
5256
5278
  idempotentHint: true,
5257
5279
  openWorldHint: false,
5258
5280
  }, async ({ jobId }) => {
5281
+ const caller = resolveOwnerPrincipal(getRequestContext());
5282
+ if (asyncJobManager.getJobSnapshot(jobId) &&
5283
+ !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
5284
+ return {
5285
+ content: [
5286
+ {
5287
+ type: "text",
5288
+ text: JSON.stringify({
5289
+ success: false,
5290
+ jobId,
5291
+ reason: "Job not found",
5292
+ }, null, 2),
5293
+ },
5294
+ ],
5295
+ isError: true,
5296
+ };
5297
+ }
5259
5298
  const cancel = asyncJobManager.cancelJob(jobId);
5260
5299
  if (!cancel.canceled) {
5261
5300
  return {
@@ -5312,7 +5351,8 @@ export function createGatewayServer(deps = {}) {
5312
5351
  maxChars,
5313
5352
  includePrompt,
5314
5353
  });
5315
- if (!record) {
5354
+ const caller = resolveOwnerPrincipal(getRequestContext());
5355
+ if (!record || !principalCanAccess(record.ownerPrincipal, caller)) {
5316
5356
  return {
5317
5357
  content: [
5318
5358
  {
@@ -5738,11 +5778,15 @@ export function createGatewayServer(deps = {}) {
5738
5778
  openWorldHint: false,
5739
5779
  }, async ({ cli }) => {
5740
5780
  try {
5741
- const sessions = await sessionManager.listSessions(cli);
5742
- const activeSessions = Object.fromEntries(await Promise.all(SESSION_PROVIDER_VALUES.map(async (provider) => [
5743
- provider,
5744
- await sessionManager.getActiveSession(provider),
5745
- ])));
5781
+ const caller = resolveOwnerPrincipal(getRequestContext());
5782
+ const sessions = (await sessionManager.listSessions(cli)).filter(s => principalCanAccess(s.ownerPrincipal, caller));
5783
+ const activeSessions = Object.fromEntries(await Promise.all(SESSION_PROVIDER_VALUES.map(async (provider) => {
5784
+ const active = await sessionManager.getActiveSession(provider);
5785
+ return [
5786
+ provider,
5787
+ active && principalCanAccess(active.ownerPrincipal, caller) ? active : null,
5788
+ ];
5789
+ })));
5746
5790
  const sessionList = sessions.map(s => ({
5747
5791
  id: s.id,
5748
5792
  cli: s.cli,
@@ -5782,6 +5826,24 @@ export function createGatewayServer(deps = {}) {
5782
5826
  openWorldHint: false,
5783
5827
  }, async ({ cli, sessionId }) => {
5784
5828
  try {
5829
+ if (sessionId) {
5830
+ const caller = resolveOwnerPrincipal(getRequestContext());
5831
+ const target = await sessionManager.getSession(sessionId);
5832
+ if (!target || !principalCanAccess(target.ownerPrincipal, caller)) {
5833
+ return {
5834
+ content: [
5835
+ {
5836
+ type: "text",
5837
+ text: JSON.stringify({
5838
+ success: false,
5839
+ error: "Session not found or does not belong to the specified provider",
5840
+ }, null, 2),
5841
+ },
5842
+ ],
5843
+ isError: true,
5844
+ };
5845
+ }
5846
+ }
5785
5847
  const success = await sessionManager.setActiveSession(cli, sessionId || null);
5786
5848
  if (!success) {
5787
5849
  return {
@@ -5826,7 +5888,8 @@ export function createGatewayServer(deps = {}) {
5826
5888
  }, async ({ sessionId }) => {
5827
5889
  try {
5828
5890
  const session = await sessionManager.getSession(sessionId);
5829
- if (!session) {
5891
+ const caller = resolveOwnerPrincipal(getRequestContext());
5892
+ if (!session || !principalCanAccess(session.ownerPrincipal, caller)) {
5830
5893
  return {
5831
5894
  content: [
5832
5895
  {
@@ -5873,7 +5936,8 @@ export function createGatewayServer(deps = {}) {
5873
5936
  }, async ({ sessionId }) => {
5874
5937
  try {
5875
5938
  const session = await sessionManager.getSession(sessionId);
5876
- if (!session) {
5939
+ const caller = resolveOwnerPrincipal(getRequestContext());
5940
+ if (!session || !principalCanAccess(session.ownerPrincipal, caller)) {
5877
5941
  return {
5878
5942
  content: [
5879
5943
  {
@@ -5941,7 +6005,13 @@ export function createGatewayServer(deps = {}) {
5941
6005
  openWorldHint: false,
5942
6006
  }, async ({ cli }) => {
5943
6007
  try {
5944
- const count = await sessionManager.clearAllSessions(cli);
6008
+ const caller = resolveOwnerPrincipal(getRequestContext());
6009
+ const owned = (await sessionManager.listSessions(cli)).filter(s => principalCanAccess(s.ownerPrincipal, caller));
6010
+ let count = 0;
6011
+ for (const s of owned) {
6012
+ if (await sessionManager.deleteSession(s.id))
6013
+ count++;
6014
+ }
5945
6015
  logger.info(`Cleared ${count} sessions${cli ? ` for ${cli}` : ""}`);
5946
6016
  return {
5947
6017
  content: [
@@ -18,6 +18,7 @@ export interface JobRecord {
18
18
  finishedAt: string | null;
19
19
  pid: number | null;
20
20
  expiresAt: string;
21
+ ownerPrincipal: string | null;
21
22
  }
22
23
  export declare function resolveJobStoreDbPath(): string | null;
23
24
  export declare function resolveJobRetentionMs(): number;
@@ -33,6 +34,7 @@ export interface JobStore {
33
34
  outputFormat?: string;
34
35
  startedAt: string;
35
36
  pid: number | null;
37
+ ownerPrincipal?: string | null;
36
38
  }): void;
37
39
  recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
38
40
  recordComplete(input: {
@@ -88,6 +90,7 @@ export declare class SqliteJobStore implements JobStore {
88
90
  outputFormat?: string;
89
91
  startedAt: string;
90
92
  pid: number | null;
93
+ ownerPrincipal?: string | null;
91
94
  }): void;
92
95
  recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
93
96
  recordComplete(input: {
@@ -127,6 +130,7 @@ export declare class MemoryJobStore implements JobStore {
127
130
  outputFormat?: string;
128
131
  startedAt: string;
129
132
  pid: number | null;
133
+ ownerPrincipal?: string | null;
130
134
  }): void;
131
135
  recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
132
136
  recordComplete(input: {
package/dist/job-store.js CHANGED
@@ -57,8 +57,16 @@ function rowToRecord(row) {
57
57
  finishedAt: row.finished_at,
58
58
  pid: row.pid,
59
59
  expiresAt: row.expires_at,
60
+ ownerPrincipal: row.owner_principal ?? null,
60
61
  };
61
62
  }
63
+ function ensureJobsOwnerColumn(db) {
64
+ const cols = db.prepare("PRAGMA table_info(jobs)").all();
65
+ const hasOwner = cols.some(col => col?.name === "owner_principal");
66
+ if (!hasOwner) {
67
+ db.exec("ALTER TABLE jobs ADD COLUMN owner_principal TEXT");
68
+ }
69
+ }
62
70
  export class SqliteJobStore {
63
71
  logger;
64
72
  db;
@@ -94,13 +102,15 @@ export class SqliteJobStore {
94
102
  started_at TEXT NOT NULL,
95
103
  finished_at TEXT,
96
104
  pid INTEGER,
97
- expires_at TEXT NOT NULL
105
+ expires_at TEXT NOT NULL,
106
+ owner_principal TEXT
98
107
  );
99
108
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key ON jobs(request_key);
100
109
  CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
101
110
  CREATE INDEX IF NOT EXISTS idx_jobs_expires_at ON jobs(expires_at);
102
111
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key_finished ON jobs(request_key, finished_at);
103
112
  `);
113
+ ensureJobsOwnerColumn(this.db);
104
114
  if (process.platform !== "win32") {
105
115
  try {
106
116
  chmodSync(dbPath, 0o600);
@@ -113,10 +123,10 @@ export class SqliteJobStore {
113
123
  this.insertStmt = this.db.prepare(`
114
124
  INSERT INTO jobs (id, correlation_id, request_key, cli, args_json, output_format,
115
125
  status, exit_code, stdout, stderr, output_truncated, error,
116
- started_at, finished_at, pid, expires_at)
126
+ started_at, finished_at, pid, expires_at, owner_principal)
117
127
  VALUES (@id, @correlation_id, @request_key, @cli, @args_json, @output_format,
118
128
  @status, @exit_code, @stdout, @stderr, @output_truncated, @error,
119
- @started_at, @finished_at, @pid, @expires_at)
129
+ @started_at, @finished_at, @pid, @expires_at, @owner_principal)
120
130
  `);
121
131
  this.updateOutputStmt = this.db.prepare(`
122
132
  UPDATE jobs SET stdout = @stdout, stderr = @stderr, output_truncated = @output_truncated
@@ -163,12 +173,13 @@ export class SqliteJobStore {
163
173
  exit_code: null,
164
174
  stdout: "",
165
175
  stderr: "",
166
- output_truncated: 0,
167
176
  error: null,
177
+ output_truncated: 0,
168
178
  started_at: input.startedAt,
169
179
  finished_at: null,
170
180
  pid: input.pid,
171
181
  expires_at: FAR_FUTURE_ISO,
182
+ owner_principal: input.ownerPrincipal ?? null,
172
183
  });
173
184
  }
174
185
  recordOutput(id, stdout, stderr, outputTruncated) {
@@ -258,6 +269,7 @@ export class MemoryJobStore {
258
269
  finishedAt: null,
259
270
  pid: input.pid,
260
271
  expiresAt: FAR_FUTURE_ISO,
272
+ ownerPrincipal: input.ownerPrincipal ?? null,
261
273
  });
262
274
  }
263
275
  recordOutput(id, stdout, stderr, outputTruncated) {
package/dist/oauth.d.ts CHANGED
@@ -33,6 +33,8 @@ export declare class OAuthServer {
33
33
  private registrationAllowedByPolicy;
34
34
  private handleRegister;
35
35
  private handleAuthorize;
36
+ private verifyConsent;
37
+ private renderConsentPage;
36
38
  private handleToken;
37
39
  private pruneExpiredCodes;
38
40
  }
package/dist/oauth.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
2
2
  import { URLSearchParams } from "node:url";
3
+ import { readCappedRawBody, maxOAuthBodyBytes } from "./request-limits.js";
3
4
  import { issueOAuthAccessToken, timingSafeStringEqual, } from "./auth.js";
4
5
  export const OAUTH_CODE_TTL_MS = 5 * 60 * 1000;
5
6
  const GENERATED_SECRET_BYTES = 32;
@@ -52,6 +53,30 @@ export function redactSecret(value) {
52
53
  function firstHeader(value) {
53
54
  return Array.isArray(value) ? value[0] : value;
54
55
  }
56
+ const HTML_ESCAPES = {
57
+ "&": "&",
58
+ "<": "&lt;",
59
+ ">": "&gt;",
60
+ '"': "&quot;",
61
+ "'": "&#39;",
62
+ };
63
+ function escapeHtml(value) {
64
+ return value.replace(/[&<>"']/g, char => HTML_ESCAPES[char] ?? char);
65
+ }
66
+ function readCookie(req, name) {
67
+ const header = firstHeader(req.headers.cookie);
68
+ if (!header)
69
+ return null;
70
+ for (const part of header.split(";")) {
71
+ const idx = part.indexOf("=");
72
+ if (idx < 0)
73
+ continue;
74
+ if (part.slice(0, idx).trim() === name) {
75
+ return decodeURIComponent(part.slice(idx + 1).trim());
76
+ }
77
+ }
78
+ return null;
79
+ }
55
80
  function methodNotAllowed(res) {
56
81
  res.writeHead(405, { allow: "GET, POST", "content-type": "application/json" });
57
82
  res.end(JSON.stringify({ error: "Method not allowed" }));
@@ -105,12 +130,7 @@ function extractStringArray(value, params, key) {
105
130
  return values.filter((item) => typeof item === "string" && item.length > 0);
106
131
  }
107
132
  async function readRawBody(req) {
108
- return new Promise((resolve, reject) => {
109
- const chunks = [];
110
- req.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
111
- req.on("error", reject);
112
- req.on("end", () => resolve(chunks.length ? Buffer.concat(chunks).toString("utf8") : ""));
113
- });
133
+ return readCappedRawBody(req, maxOAuthBodyBytes());
114
134
  }
115
135
  async function readOAuthBody(req) {
116
136
  const raw = await readRawBody(req);
@@ -272,8 +292,7 @@ export class OAuthServer {
272
292
  registrationAllowedByPolicy(req, params) {
273
293
  const policy = this.opts.config.registrationPolicy;
274
294
  if (policy === "open_dev") {
275
- const host = firstHeader(req.headers.host) ?? "";
276
- return isLocalHost(host) || process.env.LLM_GATEWAY_OAUTH_OPEN_DEV === "1";
295
+ return process.env.LLM_GATEWAY_OAUTH_OPEN_DEV === "1";
277
296
  }
278
297
  if (policy === "static_clients")
279
298
  return false;
@@ -372,6 +391,17 @@ export class OAuthServer {
372
391
  res.end();
373
392
  return;
374
393
  }
394
+ if (this.opts.config.requireConsent) {
395
+ const isSubmission = req.method === "POST" && params.get("gw_consent") === "1";
396
+ if (!isSubmission) {
397
+ this.renderConsentPage(res, params, clientId, requestedScopes);
398
+ return;
399
+ }
400
+ if (!this.verifyConsent(req, params)) {
401
+ this.renderConsentPage(res, params, clientId, requestedScopes, "Incorrect access code.");
402
+ return;
403
+ }
404
+ }
375
405
  this.pruneExpiredCodes();
376
406
  const code = randomUUID();
377
407
  this.codes.set(code, {
@@ -389,6 +419,58 @@ export class OAuthServer {
389
419
  res.writeHead(302, { location: target.toString() });
390
420
  res.end();
391
421
  }
422
+ verifyConsent(req, params) {
423
+ const cookie = readCookie(req, "gw_oauth_csrf");
424
+ const formCsrf = params.get("gw_csrf");
425
+ if (!cookie || !formCsrf || !timingSafeStringEqual(cookie, formCsrf))
426
+ return false;
427
+ const secret = params.get("consent_secret") ?? "";
428
+ const hash = this.opts.config.consentSecretHash;
429
+ return Boolean(hash && secret && verifySecret(secret, hash));
430
+ }
431
+ renderConsentPage(res, params, clientId, requestedScopes, error) {
432
+ const csrf = randomUUID();
433
+ const carried = [
434
+ ["response_type", params.get("response_type")],
435
+ ["client_id", clientId],
436
+ ["redirect_uri", params.get("redirect_uri")],
437
+ ["scope", params.get("scope")],
438
+ ["state", params.get("state")],
439
+ ["code_challenge", params.get("code_challenge")],
440
+ ["code_challenge_method", params.get("code_challenge_method")],
441
+ ["gw_consent", "1"],
442
+ ["gw_csrf", csrf],
443
+ ];
444
+ const hidden = carried
445
+ .filter(([, value]) => value != null && value !== "")
446
+ .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
447
+ .join("\n ");
448
+ const scopeList = requestedScopes.map(escapeHtml).join(", ");
449
+ const errorBlock = error ? `<p class="err">${escapeHtml(error)}</p>` : "";
450
+ const html = `<!doctype html>
451
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
452
+ <title>Authorize access</title>
453
+ <style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}
454
+ .box{border:1px solid #ddd;border-radius:8px;padding:1.5rem}label{display:block;margin:.75rem 0 .25rem}
455
+ input[type=password]{width:100%;padding:.5rem;box-sizing:border-box}button{margin-top:1rem;padding:.6rem 1.2rem}
456
+ .err{color:#b00020}.muted{color:#555;font-size:.9rem}</style></head>
457
+ <body><div class="box"><h2>Authorize access</h2>
458
+ <p>Application <strong>${escapeHtml(clientId)}</strong> is requesting access to: <strong>${scopeList}</strong>.</p>
459
+ ${errorBlock}
460
+ <form method="post" autocomplete="off">
461
+ ${hidden}
462
+ <label for="consent_secret">Gateway access code</label>
463
+ <input id="consent_secret" type="password" name="consent_secret" required autofocus>
464
+ <button type="submit">Approve</button>
465
+ </form>
466
+ <p class="muted">Only approve if you initiated this connection.</p></div></body></html>`;
467
+ res.writeHead(200, {
468
+ "content-type": "text/html; charset=utf-8",
469
+ "set-cookie": `gw_oauth_csrf=${csrf}; HttpOnly; SameSite=Lax; Path=/oauth`,
470
+ "cache-control": "no-store",
471
+ });
472
+ res.end(html);
473
+ }
392
474
  async handleToken(req, res) {
393
475
  if (req.method !== "POST") {
394
476
  methodNotAllowed(res);
package/dist/pricing.d.ts CHANGED
@@ -3,6 +3,6 @@ export interface PricePerMillion {
3
3
  outputUsd: number;
4
4
  cacheReadMultiplier: number;
5
5
  }
6
- export declare const PRICING_AS_OF = "2026-05-26";
6
+ export declare const PRICING_AS_OF = "2026-06-13";
7
7
  export declare function getPricing(cli: "claude" | "codex" | "gemini" | "grok" | "mistral", model: string): PricePerMillion;
8
8
  export declare function estimateCacheSavingsUsd(cli: "claude" | "codex" | "gemini" | "grok" | "mistral", model: string, cacheReadTokens: number): number;
package/dist/pricing.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PRICING_AS_OF = "2026-05-26";
1
+ export const PRICING_AS_OF = "2026-06-13";
2
2
  const ANTHROPIC_SONNET = {
3
3
  inputUsd: 3,
4
4
  outputUsd: 15,
@@ -19,6 +19,41 @@ const OPENAI_GPT5 = {
19
19
  outputUsd: 10,
20
20
  cacheReadMultiplier: 0.5,
21
21
  };
22
+ const GEMINI_25_PRO = {
23
+ inputUsd: 1.25,
24
+ outputUsd: 10,
25
+ cacheReadMultiplier: 0.1,
26
+ };
27
+ const GEMINI_FLASH = {
28
+ inputUsd: 0.3,
29
+ outputUsd: 2.5,
30
+ cacheReadMultiplier: 0.1,
31
+ };
32
+ const GEMINI_3_PRO = {
33
+ inputUsd: 2,
34
+ outputUsd: 12,
35
+ cacheReadMultiplier: 0.1,
36
+ };
37
+ const GROK_4 = {
38
+ inputUsd: 1.25,
39
+ outputUsd: 2.5,
40
+ cacheReadMultiplier: 0.16,
41
+ };
42
+ const GROK_BUILD = {
43
+ inputUsd: 1,
44
+ outputUsd: 2,
45
+ cacheReadMultiplier: 0.2,
46
+ };
47
+ const MISTRAL_MEDIUM = {
48
+ inputUsd: 1.5,
49
+ outputUsd: 7.5,
50
+ cacheReadMultiplier: 0.1,
51
+ };
52
+ const MISTRAL_DEVSTRAL = {
53
+ inputUsd: 0.1,
54
+ outputUsd: 0.3,
55
+ cacheReadMultiplier: 0.1,
56
+ };
22
57
  const ZERO = {
23
58
  inputUsd: 0,
24
59
  outputUsd: 0,
@@ -33,13 +68,43 @@ export function getPricing(cli, model) {
33
68
  return ANTHROPIC_OPUS;
34
69
  if (lower.includes("haiku"))
35
70
  return ANTHROPIC_HAIKU;
36
- return ZERO;
71
+ return ANTHROPIC_SONNET;
37
72
  }
38
73
  if (cli === "codex") {
39
74
  if (lower.includes("gpt-5") || lower.includes("o3"))
40
75
  return OPENAI_GPT5;
41
76
  return ZERO;
42
77
  }
78
+ if (cli === "gemini") {
79
+ const specialtyVariant = lower.includes("lite") ||
80
+ lower.includes("image") ||
81
+ lower.includes("audio") ||
82
+ lower.includes("tts");
83
+ if (!specialtyVariant) {
84
+ if (lower.includes("2.5-flash"))
85
+ return GEMINI_FLASH;
86
+ if (lower.includes("2.5-pro"))
87
+ return GEMINI_25_PRO;
88
+ if (lower.includes("gemini-3") && lower.includes("pro"))
89
+ return GEMINI_3_PRO;
90
+ }
91
+ return ZERO;
92
+ }
93
+ if (cli === "grok") {
94
+ if (lower.includes("grok-build") || lower.includes("grok-code"))
95
+ return GROK_BUILD;
96
+ if (lower.includes("grok-4") || lower.includes("grok-3") || lower.includes("grok-latest")) {
97
+ return GROK_4;
98
+ }
99
+ return ZERO;
100
+ }
101
+ if (cli === "mistral") {
102
+ if (lower.includes("devstral-small"))
103
+ return MISTRAL_DEVSTRAL;
104
+ if (lower.includes("mistral-medium-3.5"))
105
+ return MISTRAL_MEDIUM;
106
+ return ZERO;
107
+ }
43
108
  return ZERO;
44
109
  }
45
110
  export function estimateCacheSavingsUsd(cli, model, cacheReadTokens) {