llm-cli-gateway 2.8.0 → 2.10.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 +85 -0
- package/README.md +23 -0
- package/dist/approval-manager.d.ts +1 -0
- package/dist/approval-manager.js +14 -1
- package/dist/async-job-manager.d.ts +1 -0
- package/dist/async-job-manager.js +11 -0
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +16 -0
- package/dist/cache-stats.d.ts +1 -0
- package/dist/cache-stats.js +2 -1
- package/dist/cli-updater.js +5 -2
- package/dist/config.js +22 -1
- package/dist/flight-recorder.d.ts +7 -1
- package/dist/flight-recorder.js +33 -6
- package/dist/http-transport.js +19 -17
- package/dist/index.js +117 -29
- package/dist/job-store.d.ts +4 -0
- package/dist/job-store.js +16 -4
- package/dist/oauth.d.ts +2 -0
- package/dist/oauth.js +90 -8
- package/dist/request-context.d.ts +3 -0
- package/dist/request-context.js +16 -0
- package/dist/request-limits.d.ts +8 -0
- package/dist/request-limits.js +49 -0
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +24 -15
- package/dist/secret-redaction.d.ts +3 -0
- package/dist/secret-redaction.js +53 -0
- package/dist/session-manager-pg.js +8 -5
- package/dist/session-manager.d.ts +1 -0
- package/dist/session-manager.js +2 -0
- package/migrations/004_session_owner_principal.sql +10 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -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";
|
|
@@ -434,7 +434,7 @@ export async function resolveWorktreeForRequest(worktreeOpt, sessionId, runtime,
|
|
|
434
434
|
return {};
|
|
435
435
|
const sessionManager = runtime.sessionManager;
|
|
436
436
|
if (sessionId) {
|
|
437
|
-
const session = await
|
|
437
|
+
const session = await getCallerOwnedSession(sessionManager, sessionId);
|
|
438
438
|
const existingPath = session?.metadata?.worktreePath;
|
|
439
439
|
if (typeof existingPath === "string" && existingPath.length > 0) {
|
|
440
440
|
return {
|
|
@@ -477,9 +477,7 @@ function isGatewayAppDirCwd() {
|
|
|
477
477
|
return process.cwd() === join(homedir(), ".llm-cli-gateway");
|
|
478
478
|
}
|
|
479
479
|
async function resolveWorkspaceAndWorktreeForRequest(args) {
|
|
480
|
-
const session = args.sessionId
|
|
481
|
-
? await Promise.resolve(args.runtime.sessionManager.getSession(args.sessionId))
|
|
482
|
-
: null;
|
|
480
|
+
const session = await getCallerOwnedSession(args.runtime.sessionManager, args.sessionId);
|
|
483
481
|
let workspace;
|
|
484
482
|
if (args.workspace ||
|
|
485
483
|
args.runtime.workspaces.defaultAlias ||
|
|
@@ -1274,7 +1272,8 @@ export function prepareClaudeRequest(params, runtime = resolveGatewayServerRunti
|
|
|
1274
1272
|
args.push("--disallowed-tools", ...params.disallowedTools);
|
|
1275
1273
|
}
|
|
1276
1274
|
if (params.approvalStrategy === "mcp_managed") {
|
|
1277
|
-
|
|
1275
|
+
const managedMode = bypassAllowedByOperator() ? "bypassPermissions" : "acceptEdits";
|
|
1276
|
+
args.push("--permission-mode", managedMode);
|
|
1278
1277
|
}
|
|
1279
1278
|
else {
|
|
1280
1279
|
const permFlags = resolveClaudePermissionFlags({
|
|
@@ -1539,7 +1538,11 @@ export function prepareGeminiRequest(params, runtime = resolveGatewayServerRunti
|
|
|
1539
1538
|
return createApprovalDeniedResponse(params.operation, approvalDecision);
|
|
1540
1539
|
}
|
|
1541
1540
|
}
|
|
1542
|
-
const effectiveApprovalMode = params.approvalStrategy === "mcp_managed"
|
|
1541
|
+
const effectiveApprovalMode = params.approvalStrategy === "mcp_managed"
|
|
1542
|
+
? bypassAllowedByOperator()
|
|
1543
|
+
? "yolo"
|
|
1544
|
+
: "default"
|
|
1545
|
+
: params.approvalMode;
|
|
1543
1546
|
const unsupported = (field, detail) => createErrorResponse(params.operation, 1, "", corrId, new Error(`${field} is not supported by Antigravity CLI (agy): ${detail}`));
|
|
1544
1547
|
if (effectiveApprovalMode &&
|
|
1545
1548
|
effectiveApprovalMode !== "default" &&
|
|
@@ -1646,14 +1649,22 @@ export function prepareGrokRequest(params, runtime = resolveGatewayServerRuntime
|
|
|
1646
1649
|
return createApprovalDeniedResponse(params.operation, approvalDecision);
|
|
1647
1650
|
}
|
|
1648
1651
|
}
|
|
1649
|
-
const
|
|
1652
|
+
const managedGrokBypass = params.approvalStrategy === "mcp_managed" && bypassAllowedByOperator();
|
|
1650
1653
|
const grokContract = UPSTREAM_CLI_CONTRACTS.grok;
|
|
1651
1654
|
const genParams = params;
|
|
1652
1655
|
const args = ["-p", effectivePrompt];
|
|
1653
1656
|
if (resolvedModel)
|
|
1654
1657
|
args.push("--model", resolvedModel);
|
|
1655
1658
|
args.push(...buildArgvFromGeneration(grokContract, GROK_GEN_OUTPUT_FORMAT, genParams));
|
|
1656
|
-
if (
|
|
1659
|
+
if (params.approvalStrategy === "mcp_managed") {
|
|
1660
|
+
if (managedGrokBypass) {
|
|
1661
|
+
args.push("--always-approve");
|
|
1662
|
+
}
|
|
1663
|
+
else {
|
|
1664
|
+
args.push("--permission-mode", "acceptEdits");
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
else if (Boolean(params.alwaysApprove)) {
|
|
1657
1668
|
args.push("--always-approve");
|
|
1658
1669
|
}
|
|
1659
1670
|
else if (params.permissionMode) {
|
|
@@ -1760,7 +1771,9 @@ export function prepareMistralRequest(params, runtime = resolveGatewayServerRunt
|
|
|
1760
1771
|
}
|
|
1761
1772
|
}
|
|
1762
1773
|
const effectivePermissionMode = params.approvalStrategy === "mcp_managed"
|
|
1763
|
-
?
|
|
1774
|
+
? bypassAllowedByOperator()
|
|
1775
|
+
? "auto-approve"
|
|
1776
|
+
: "accept-edits"
|
|
1764
1777
|
: (params.permissionMode ?? "auto-approve");
|
|
1765
1778
|
const prep = buildMistralCliInvocation({
|
|
1766
1779
|
prompt: effectivePrompt,
|
|
@@ -1811,7 +1824,9 @@ export function buildMistralRetryPrep(params, recoveryModel) {
|
|
|
1811
1824
|
resolvedModel: recoveryModel,
|
|
1812
1825
|
outputFormat: params.outputFormat,
|
|
1813
1826
|
permissionMode: params.approvalStrategy === "mcp_managed"
|
|
1814
|
-
?
|
|
1827
|
+
? bypassAllowedByOperator()
|
|
1828
|
+
? "auto-approve"
|
|
1829
|
+
: "accept-edits"
|
|
1815
1830
|
: (params.permissionMode ?? "auto-approve"),
|
|
1816
1831
|
allowedTools: params.allowedTools,
|
|
1817
1832
|
disallowedTools: params.disallowedTools,
|
|
@@ -1955,11 +1970,33 @@ function usageFromXaiResult(result) {
|
|
|
1955
1970
|
costUsd: result.usage.costUsd,
|
|
1956
1971
|
};
|
|
1957
1972
|
}
|
|
1973
|
+
function callerCanAccessSession(session) {
|
|
1974
|
+
return principalCanAccess(session.ownerPrincipal, resolveOwnerPrincipal(getRequestContext()));
|
|
1975
|
+
}
|
|
1976
|
+
async function getCallerOwnedSession(sessionManager, sessionId) {
|
|
1977
|
+
if (!sessionId)
|
|
1978
|
+
return null;
|
|
1979
|
+
const existing = await Promise.resolve(sessionManager.getSession(sessionId));
|
|
1980
|
+
if (!existing || !callerCanAccessSession(existing))
|
|
1981
|
+
return null;
|
|
1982
|
+
return existing;
|
|
1983
|
+
}
|
|
1984
|
+
async function getCallerOwnedActiveSession(sessionManager, provider) {
|
|
1985
|
+
const active = await Promise.resolve(sessionManager.getActiveSession(provider));
|
|
1986
|
+
if (!active || !callerCanAccessSession(active))
|
|
1987
|
+
return null;
|
|
1988
|
+
return active;
|
|
1989
|
+
}
|
|
1958
1990
|
async function getExistingSessionForProvider(sessionManager, sessionId, provider) {
|
|
1959
1991
|
if (!sessionId)
|
|
1960
1992
|
return null;
|
|
1961
1993
|
const existing = await sessionManager.getSession(sessionId);
|
|
1962
|
-
if (existing
|
|
1994
|
+
if (!existing)
|
|
1995
|
+
return null;
|
|
1996
|
+
if (!callerCanAccessSession(existing)) {
|
|
1997
|
+
throw new Error(`Session ${sessionId} is not accessible`);
|
|
1998
|
+
}
|
|
1999
|
+
if (existing.cli !== provider) {
|
|
1963
2000
|
throw new Error(`Session ${sessionId} belongs to provider '${existing.cli}', not '${provider}'`);
|
|
1964
2001
|
}
|
|
1965
2002
|
return existing;
|
|
@@ -2012,7 +2049,7 @@ async function resolveGrokApiSession(params, runtime) {
|
|
|
2012
2049
|
return { sessionId: session.id, previousResponseId: previous };
|
|
2013
2050
|
}
|
|
2014
2051
|
if (!params.createNewSession) {
|
|
2015
|
-
const active = await runtime.sessionManager
|
|
2052
|
+
const active = await getCallerOwnedActiveSession(runtime.sessionManager, "grok-api");
|
|
2016
2053
|
if (active) {
|
|
2017
2054
|
const previous = typeof active.metadata?.xaiPreviousResponseId === "string"
|
|
2018
2055
|
? active.metadata.xaiPreviousResponseId
|
|
@@ -3039,7 +3076,7 @@ export async function handleCodexRequestAsync(deps, params) {
|
|
|
3039
3076
|
try {
|
|
3040
3077
|
let effectiveSessionId = params.sessionId;
|
|
3041
3078
|
if (!params.createNewSession && !params.sessionId) {
|
|
3042
|
-
const activeSession = await deps.sessionManager
|
|
3079
|
+
const activeSession = await getCallerOwnedActiveSession(deps.sessionManager, "codex");
|
|
3043
3080
|
if (activeSession) {
|
|
3044
3081
|
effectiveSessionId = activeSession.id;
|
|
3045
3082
|
}
|
|
@@ -3049,6 +3086,7 @@ export async function handleCodexRequestAsync(deps, params) {
|
|
|
3049
3086
|
}
|
|
3050
3087
|
}
|
|
3051
3088
|
else if (params.sessionId) {
|
|
3089
|
+
await getExistingSessionForProvider(deps.sessionManager, params.sessionId, "codex");
|
|
3052
3090
|
await deps.sessionManager.updateSessionUsage(params.sessionId);
|
|
3053
3091
|
}
|
|
3054
3092
|
else if (params.createNewSession) {
|
|
@@ -3389,7 +3427,7 @@ export function createGatewayServer(deps = {}) {
|
|
|
3389
3427
|
let useContinue = continueSession;
|
|
3390
3428
|
let activeSession = null;
|
|
3391
3429
|
try {
|
|
3392
|
-
activeSession = await sessionManager
|
|
3430
|
+
activeSession = await getCallerOwnedActiveSession(sessionManager, "claude");
|
|
3393
3431
|
}
|
|
3394
3432
|
catch (err) {
|
|
3395
3433
|
logger.warn(`[${corrId}] sessionManager.getActiveSession failed (non-fatal): ${err.message}`);
|
|
@@ -3758,7 +3796,7 @@ export function createGatewayServer(deps = {}) {
|
|
|
3758
3796
|
wasSuccessful = true;
|
|
3759
3797
|
let effectiveSessionId = sessionId;
|
|
3760
3798
|
if (!createNewSession && !sessionId) {
|
|
3761
|
-
const activeSession = await sessionManager
|
|
3799
|
+
const activeSession = await getCallerOwnedActiveSession(sessionManager, "codex");
|
|
3762
3800
|
if (activeSession) {
|
|
3763
3801
|
effectiveSessionId = activeSession.id;
|
|
3764
3802
|
}
|
|
@@ -4469,7 +4507,7 @@ export function createGatewayServer(deps = {}) {
|
|
|
4469
4507
|
try {
|
|
4470
4508
|
let effectiveSessionId = sessionId;
|
|
4471
4509
|
let useContinue = continueSession;
|
|
4472
|
-
const activeSession = await sessionManager
|
|
4510
|
+
const activeSession = await getCallerOwnedActiveSession(sessionManager, "claude");
|
|
4473
4511
|
if (!createNewSession && !continueSession && !sessionId && activeSession) {
|
|
4474
4512
|
effectiveSessionId = activeSession.id;
|
|
4475
4513
|
useContinue = true;
|
|
@@ -5158,7 +5196,8 @@ export function createGatewayServer(deps = {}) {
|
|
|
5158
5196
|
openWorldHint: false,
|
|
5159
5197
|
}, async ({ jobId }) => {
|
|
5160
5198
|
const job = asyncJobManager.getJobSnapshot(jobId);
|
|
5161
|
-
|
|
5199
|
+
const caller = resolveOwnerPrincipal(getRequestContext());
|
|
5200
|
+
if (!job || !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
|
|
5162
5201
|
return {
|
|
5163
5202
|
content: [
|
|
5164
5203
|
{
|
|
@@ -5202,7 +5241,8 @@ export function createGatewayServer(deps = {}) {
|
|
|
5202
5241
|
openWorldHint: false,
|
|
5203
5242
|
}, async ({ jobId, maxChars }) => {
|
|
5204
5243
|
const result = asyncJobManager.getJobResult(jobId, maxChars);
|
|
5205
|
-
|
|
5244
|
+
const caller = resolveOwnerPrincipal(getRequestContext());
|
|
5245
|
+
if (!result || !principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
|
|
5206
5246
|
return {
|
|
5207
5247
|
content: [
|
|
5208
5248
|
{
|
|
@@ -5259,6 +5299,23 @@ export function createGatewayServer(deps = {}) {
|
|
|
5259
5299
|
idempotentHint: true,
|
|
5260
5300
|
openWorldHint: false,
|
|
5261
5301
|
}, async ({ jobId }) => {
|
|
5302
|
+
const caller = resolveOwnerPrincipal(getRequestContext());
|
|
5303
|
+
if (asyncJobManager.getJobSnapshot(jobId) &&
|
|
5304
|
+
!principalCanAccess(asyncJobManager.getJobOwner(jobId), caller)) {
|
|
5305
|
+
return {
|
|
5306
|
+
content: [
|
|
5307
|
+
{
|
|
5308
|
+
type: "text",
|
|
5309
|
+
text: JSON.stringify({
|
|
5310
|
+
success: false,
|
|
5311
|
+
jobId,
|
|
5312
|
+
reason: "Job not found",
|
|
5313
|
+
}, null, 2),
|
|
5314
|
+
},
|
|
5315
|
+
],
|
|
5316
|
+
isError: true,
|
|
5317
|
+
};
|
|
5318
|
+
}
|
|
5262
5319
|
const cancel = asyncJobManager.cancelJob(jobId);
|
|
5263
5320
|
if (!cancel.canceled) {
|
|
5264
5321
|
return {
|
|
@@ -5315,7 +5372,8 @@ export function createGatewayServer(deps = {}) {
|
|
|
5315
5372
|
maxChars,
|
|
5316
5373
|
includePrompt,
|
|
5317
5374
|
});
|
|
5318
|
-
|
|
5375
|
+
const caller = resolveOwnerPrincipal(getRequestContext());
|
|
5376
|
+
if (!record || !principalCanAccess(record.ownerPrincipal, caller)) {
|
|
5319
5377
|
return {
|
|
5320
5378
|
content: [
|
|
5321
5379
|
{
|
|
@@ -5741,11 +5799,15 @@ export function createGatewayServer(deps = {}) {
|
|
|
5741
5799
|
openWorldHint: false,
|
|
5742
5800
|
}, async ({ cli }) => {
|
|
5743
5801
|
try {
|
|
5744
|
-
const
|
|
5745
|
-
const
|
|
5746
|
-
|
|
5747
|
-
await sessionManager.getActiveSession(provider)
|
|
5748
|
-
|
|
5802
|
+
const caller = resolveOwnerPrincipal(getRequestContext());
|
|
5803
|
+
const sessions = (await sessionManager.listSessions(cli)).filter(s => principalCanAccess(s.ownerPrincipal, caller));
|
|
5804
|
+
const activeSessions = Object.fromEntries(await Promise.all(SESSION_PROVIDER_VALUES.map(async (provider) => {
|
|
5805
|
+
const active = await sessionManager.getActiveSession(provider);
|
|
5806
|
+
return [
|
|
5807
|
+
provider,
|
|
5808
|
+
active && principalCanAccess(active.ownerPrincipal, caller) ? active : null,
|
|
5809
|
+
];
|
|
5810
|
+
})));
|
|
5749
5811
|
const sessionList = sessions.map(s => ({
|
|
5750
5812
|
id: s.id,
|
|
5751
5813
|
cli: s.cli,
|
|
@@ -5785,6 +5847,24 @@ export function createGatewayServer(deps = {}) {
|
|
|
5785
5847
|
openWorldHint: false,
|
|
5786
5848
|
}, async ({ cli, sessionId }) => {
|
|
5787
5849
|
try {
|
|
5850
|
+
if (sessionId) {
|
|
5851
|
+
const caller = resolveOwnerPrincipal(getRequestContext());
|
|
5852
|
+
const target = await sessionManager.getSession(sessionId);
|
|
5853
|
+
if (!target || !principalCanAccess(target.ownerPrincipal, caller)) {
|
|
5854
|
+
return {
|
|
5855
|
+
content: [
|
|
5856
|
+
{
|
|
5857
|
+
type: "text",
|
|
5858
|
+
text: JSON.stringify({
|
|
5859
|
+
success: false,
|
|
5860
|
+
error: "Session not found or does not belong to the specified provider",
|
|
5861
|
+
}, null, 2),
|
|
5862
|
+
},
|
|
5863
|
+
],
|
|
5864
|
+
isError: true,
|
|
5865
|
+
};
|
|
5866
|
+
}
|
|
5867
|
+
}
|
|
5788
5868
|
const success = await sessionManager.setActiveSession(cli, sessionId || null);
|
|
5789
5869
|
if (!success) {
|
|
5790
5870
|
return {
|
|
@@ -5829,7 +5909,8 @@ export function createGatewayServer(deps = {}) {
|
|
|
5829
5909
|
}, async ({ sessionId }) => {
|
|
5830
5910
|
try {
|
|
5831
5911
|
const session = await sessionManager.getSession(sessionId);
|
|
5832
|
-
|
|
5912
|
+
const caller = resolveOwnerPrincipal(getRequestContext());
|
|
5913
|
+
if (!session || !principalCanAccess(session.ownerPrincipal, caller)) {
|
|
5833
5914
|
return {
|
|
5834
5915
|
content: [
|
|
5835
5916
|
{
|
|
@@ -5876,7 +5957,8 @@ export function createGatewayServer(deps = {}) {
|
|
|
5876
5957
|
}, async ({ sessionId }) => {
|
|
5877
5958
|
try {
|
|
5878
5959
|
const session = await sessionManager.getSession(sessionId);
|
|
5879
|
-
|
|
5960
|
+
const caller = resolveOwnerPrincipal(getRequestContext());
|
|
5961
|
+
if (!session || !principalCanAccess(session.ownerPrincipal, caller)) {
|
|
5880
5962
|
return {
|
|
5881
5963
|
content: [
|
|
5882
5964
|
{
|
|
@@ -5944,7 +6026,13 @@ export function createGatewayServer(deps = {}) {
|
|
|
5944
6026
|
openWorldHint: false,
|
|
5945
6027
|
}, async ({ cli }) => {
|
|
5946
6028
|
try {
|
|
5947
|
-
const
|
|
6029
|
+
const caller = resolveOwnerPrincipal(getRequestContext());
|
|
6030
|
+
const owned = (await sessionManager.listSessions(cli)).filter(s => principalCanAccess(s.ownerPrincipal, caller));
|
|
6031
|
+
let count = 0;
|
|
6032
|
+
for (const s of owned) {
|
|
6033
|
+
if (await sessionManager.deleteSession(s.id))
|
|
6034
|
+
count++;
|
|
6035
|
+
}
|
|
5948
6036
|
logger.info(`Cleared ${count} sessions${cli ? ` for ${cli}` : ""}`);
|
|
5949
6037
|
return {
|
|
5950
6038
|
content: [
|
package/dist/job-store.d.ts
CHANGED
|
@@ -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
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
|
+
"<": "<",
|
|
59
|
+
">": ">",
|
|
60
|
+
'"': """,
|
|
61
|
+
"'": "'",
|
|
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
|
|
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
|
-
|
|
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);
|
|
@@ -3,6 +3,9 @@ export interface GatewayRequestContext {
|
|
|
3
3
|
authKind?: "disabled" | "gateway_bearer" | "oauth";
|
|
4
4
|
authScopes: string[];
|
|
5
5
|
authClientId?: string;
|
|
6
|
+
authPrincipal?: string;
|
|
6
7
|
}
|
|
8
|
+
export declare function resolveOwnerPrincipal(ctx: GatewayRequestContext | undefined): string;
|
|
9
|
+
export declare function principalCanAccess(rowOwner: string | null | undefined, caller: string): boolean;
|
|
7
10
|
export declare function runWithRequestContext<T>(context: GatewayRequestContext, callback: () => T | Promise<T>): T | Promise<T>;
|
|
8
11
|
export declare function getRequestContext(): GatewayRequestContext | undefined;
|
package/dist/request-context.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
const requestContext = new AsyncLocalStorage();
|
|
3
|
+
export function resolveOwnerPrincipal(ctx) {
|
|
4
|
+
if (!ctx)
|
|
5
|
+
return "local";
|
|
6
|
+
if (ctx.authPrincipal)
|
|
7
|
+
return ctx.authPrincipal;
|
|
8
|
+
if (ctx.authKind === "gateway_bearer")
|
|
9
|
+
return "gateway-bearer";
|
|
10
|
+
return "local";
|
|
11
|
+
}
|
|
12
|
+
export function principalCanAccess(rowOwner, caller) {
|
|
13
|
+
if (rowOwner === caller)
|
|
14
|
+
return true;
|
|
15
|
+
if ((rowOwner === null || rowOwner === undefined) && caller === "local")
|
|
16
|
+
return true;
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
3
19
|
export function runWithRequestContext(context, callback) {
|
|
4
20
|
return requestContext.run(context, callback);
|
|
5
21
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
export declare class PayloadTooLargeError extends Error {
|
|
3
|
+
readonly statusCode = 413;
|
|
4
|
+
constructor(maxBytes: number);
|
|
5
|
+
}
|
|
6
|
+
export declare function maxHttpBodyBytes(env?: NodeJS.ProcessEnv): number;
|
|
7
|
+
export declare function maxOAuthBodyBytes(env?: NodeJS.ProcessEnv): number;
|
|
8
|
+
export declare function readCappedRawBody(req: IncomingMessage, maxBytes: number): Promise<string>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export class PayloadTooLargeError extends Error {
|
|
2
|
+
statusCode = 413;
|
|
3
|
+
constructor(maxBytes) {
|
|
4
|
+
super(`Request body exceeds maximum size (${maxBytes} bytes)`);
|
|
5
|
+
this.name = "PayloadTooLargeError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
const DEFAULT_MAX_HTTP_BODY_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
const DEFAULT_MAX_OAUTH_BODY_BYTES = 64 * 1024;
|
|
10
|
+
function positiveIntFromEnv(value, fallback) {
|
|
11
|
+
const parsed = Number(value);
|
|
12
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
13
|
+
}
|
|
14
|
+
export function maxHttpBodyBytes(env = process.env) {
|
|
15
|
+
return positiveIntFromEnv(env.LLM_GATEWAY_MAX_HTTP_BODY_BYTES, DEFAULT_MAX_HTTP_BODY_BYTES);
|
|
16
|
+
}
|
|
17
|
+
export function maxOAuthBodyBytes(env = process.env) {
|
|
18
|
+
return positiveIntFromEnv(env.LLM_GATEWAY_MAX_OAUTH_BODY_BYTES, DEFAULT_MAX_OAUTH_BODY_BYTES);
|
|
19
|
+
}
|
|
20
|
+
export function readCappedRawBody(req, maxBytes) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let total = 0;
|
|
24
|
+
let aborted = false;
|
|
25
|
+
req.on("data", (chunk) => {
|
|
26
|
+
if (aborted)
|
|
27
|
+
return;
|
|
28
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
29
|
+
total += buf.length;
|
|
30
|
+
if (total > maxBytes) {
|
|
31
|
+
aborted = true;
|
|
32
|
+
chunks.length = 0;
|
|
33
|
+
reject(new PayloadTooLargeError(maxBytes));
|
|
34
|
+
req.resume();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
chunks.push(buf);
|
|
38
|
+
});
|
|
39
|
+
req.on("error", err => {
|
|
40
|
+
if (!aborted)
|
|
41
|
+
reject(err);
|
|
42
|
+
});
|
|
43
|
+
req.on("end", () => {
|
|
44
|
+
if (aborted)
|
|
45
|
+
return;
|
|
46
|
+
resolve(chunks.length === 0 ? "" : Buffer.concat(chunks).toString("utf8"));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|