llm-cli-gateway 2.13.1 → 2.13.2
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 +43 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +58 -19
- package/dist/connector-setup.d.ts +39 -0
- package/dist/connector-setup.js +86 -0
- package/dist/doctor.d.ts +39 -1
- package/dist/doctor.js +182 -3
- package/dist/http-transport.js +27 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +149 -25
- package/dist/oauth.js +15 -14
- package/dist/remote-url.d.ts +28 -0
- package/dist/remote-url.js +61 -0
- package/dist/resources.js +3 -1
- package/dist/session-manager.d.ts +2 -0
- package/dist/session-manager.js +20 -1
- package/dist/workspace-registry.d.ts +9 -0
- package/dist/workspace-registry.js +24 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/setup/status.schema.json +75 -0
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { randomUUID } from "crypto";
|
|
5
5
|
import { createRequire } from "module";
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, chmodSync, } from "fs";
|
|
7
|
-
import { dirname, join } from "path";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative } from "path";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
import { z } from "zod/v3";
|
|
10
10
|
import { executeCli, killAllProcessGroups, providerCommandName } from "./executor.js";
|
|
@@ -13,7 +13,7 @@ import { parseCodexJsonStream, codexDisplayText, codexFrResponse } from "./codex
|
|
|
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";
|
|
16
|
-
import { CLI_TYPES, PROVIDER_TYPES, createSessionManager, } from "./session-manager.js";
|
|
16
|
+
import { CLI_TYPES, PROVIDER_TYPES, createSessionManager, callerIsRemote, remoteSafeSession, } from "./session-manager.js";
|
|
17
17
|
import { createWorktree, createWorktreeSessionCleanupHook, } from "./worktree-manager.js";
|
|
18
18
|
import { ResourceProvider } from "./resources.js";
|
|
19
19
|
import { PerformanceMetrics } from "./metrics.js";
|
|
@@ -41,7 +41,9 @@ import { startHttpGateway } from "./http-transport.js";
|
|
|
41
41
|
import { getRequestContext, resolveOwnerPrincipal, principalCanAccess } from "./request-context.js";
|
|
42
42
|
import { printDoctorJson } from "./doctor.js";
|
|
43
43
|
import { redactDiagnosticUrl } from "./endpoint-exposure.js";
|
|
44
|
-
import {
|
|
44
|
+
import { buildRemoteConnectorUrls } from "./remote-url.js";
|
|
45
|
+
import { gatherConnectorSetupPacket, renderConnectorSetupSummary, } from "./connector-setup.js";
|
|
46
|
+
import { createWorkspace, describeWorkspace, describeWorkspaceRemote, getWorkspace, loadWorkspaceRegistry, registerExistingWorkspace, resolveWorkspaceForProvider, validatePathInsideWorkspace, } from "./workspace-registry.js";
|
|
45
47
|
import { generateSecret, hashSecret } from "./oauth.js";
|
|
46
48
|
import { registerValidationTools } from "./validation-tools.js";
|
|
47
49
|
import { currentCaller, resolveValidationReceipt } from "./validation-receipt.js";
|
|
@@ -738,7 +740,10 @@ async function resolveWorkspaceAndWorktreeForRequest(args) {
|
|
|
738
740
|
workspaceAlias: workspace?.alias,
|
|
739
741
|
workspaceRoot: workspace?.root,
|
|
740
742
|
});
|
|
741
|
-
|
|
743
|
+
const displayWorktreePath = isRemoteTransport
|
|
744
|
+
? remoteSafeWorktreePath(resolved.worktreePath, workspace?.root)
|
|
745
|
+
: resolved.worktreePath;
|
|
746
|
+
return { cwd: resolved.cwd, worktreePath: displayWorktreePath, workspace };
|
|
742
747
|
}
|
|
743
748
|
if (workspace && args.sessionId) {
|
|
744
749
|
await Promise.resolve(args.runtime.sessionManager.updateSessionMetadata(args.sessionId, {
|
|
@@ -751,6 +756,16 @@ async function resolveWorkspaceAndWorktreeForRequest(args) {
|
|
|
751
756
|
export function formatWorktreePrefix(worktreePath) {
|
|
752
757
|
return worktreePath ? `[gateway] worktree=${worktreePath}\n` : "";
|
|
753
758
|
}
|
|
759
|
+
export function remoteSafeWorktreePath(worktreePath, workspaceRoot) {
|
|
760
|
+
if (!worktreePath)
|
|
761
|
+
return worktreePath;
|
|
762
|
+
if (workspaceRoot) {
|
|
763
|
+
const rel = relative(workspaceRoot, worktreePath);
|
|
764
|
+
if (rel && !rel.startsWith("..") && !isAbsolute(rel))
|
|
765
|
+
return rel;
|
|
766
|
+
}
|
|
767
|
+
return basename(worktreePath);
|
|
768
|
+
}
|
|
754
769
|
function workspaceAdminEnabled() {
|
|
755
770
|
const scopes = getRequestContext()?.authScopes ?? [];
|
|
756
771
|
return process.env.LLM_GATEWAY_WORKSPACE_ADMIN === "1" && scopes.includes("workspace:admin");
|
|
@@ -780,10 +795,9 @@ function registerWorkspaceTools(server, runtime) {
|
|
|
780
795
|
success: true,
|
|
781
796
|
enabled: registry.enabled,
|
|
782
797
|
default: registry.defaultAlias,
|
|
783
|
-
workspaces: registry.repos.map(
|
|
798
|
+
workspaces: registry.repos.map(describeWorkspaceRemote),
|
|
784
799
|
allowed_roots: registry.allowedRoots.map(root => ({
|
|
785
800
|
alias: root.alias,
|
|
786
|
-
path: root.path,
|
|
787
801
|
allow_register_existing_git_repos: root.allowRegisterExistingGitRepos,
|
|
788
802
|
allow_create_directories: root.allowCreateDirectories,
|
|
789
803
|
allow_init_git_repos: root.allowInitGitRepos,
|
|
@@ -812,7 +826,10 @@ function registerWorkspaceTools(server, runtime) {
|
|
|
812
826
|
content: [
|
|
813
827
|
{
|
|
814
828
|
type: "text",
|
|
815
|
-
text: JSON.stringify({
|
|
829
|
+
text: JSON.stringify({
|
|
830
|
+
success: true,
|
|
831
|
+
workspace: describeWorkspaceRemote(getWorkspace(registry, alias)),
|
|
832
|
+
}, null, 2),
|
|
816
833
|
},
|
|
817
834
|
],
|
|
818
835
|
};
|
|
@@ -851,7 +868,7 @@ function registerWorkspaceTools(server, runtime) {
|
|
|
851
868
|
content: [
|
|
852
869
|
{
|
|
853
870
|
type: "text",
|
|
854
|
-
text: JSON.stringify({ success: true, workspace:
|
|
871
|
+
text: JSON.stringify({ success: true, workspace: describeWorkspaceRemote(repo) }, null, 2),
|
|
855
872
|
},
|
|
856
873
|
],
|
|
857
874
|
};
|
|
@@ -889,7 +906,7 @@ function registerWorkspaceTools(server, runtime) {
|
|
|
889
906
|
content: [
|
|
890
907
|
{
|
|
891
908
|
type: "text",
|
|
892
|
-
text: JSON.stringify({ success: true, workspace:
|
|
909
|
+
text: JSON.stringify({ success: true, workspace: describeWorkspaceRemote(repo) }, null, 2),
|
|
893
910
|
},
|
|
894
911
|
],
|
|
895
912
|
};
|
|
@@ -7789,7 +7806,7 @@ export function createGatewayServer(deps = {}) {
|
|
|
7789
7806
|
text: JSON.stringify({
|
|
7790
7807
|
success: true,
|
|
7791
7808
|
session: {
|
|
7792
|
-
...session,
|
|
7809
|
+
...(callerIsRemote() ? remoteSafeSession(session) : session),
|
|
7793
7810
|
isActive: activeSession?.id === session.id,
|
|
7794
7811
|
...(cacheState ? { cacheState } : {}),
|
|
7795
7812
|
},
|
|
@@ -7981,6 +7998,56 @@ function localBaseUrlForPrint() {
|
|
|
7981
7998
|
function printJsonLine(value) {
|
|
7982
7999
|
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
7983
8000
|
}
|
|
8001
|
+
const OAUTH_RESTART_NOTE = "Restart the gateway HTTP transport for this change to take effect (OAuth config is loaded at startup).";
|
|
8002
|
+
function validateCliRedirectUri(uri) {
|
|
8003
|
+
let parsed;
|
|
8004
|
+
try {
|
|
8005
|
+
parsed = new URL(uri);
|
|
8006
|
+
}
|
|
8007
|
+
catch {
|
|
8008
|
+
throw new Error(`Invalid --redirect-uri "${uri}": it must be an absolute URL with a scheme (e.g. https://chatgpt.com/connector/callback).`);
|
|
8009
|
+
}
|
|
8010
|
+
if (parsed.protocol === "https:")
|
|
8011
|
+
return;
|
|
8012
|
+
if (parsed.protocol === "http:" && hostIsLoopback(parsed.hostname))
|
|
8013
|
+
return;
|
|
8014
|
+
throw new Error(`Invalid --redirect-uri "${uri}": must be https:// (or http:// only for localhost/loopback). The gateway rejects http non-loopback redirect URIs at runtime.`);
|
|
8015
|
+
}
|
|
8016
|
+
function validateCliClientId(clientId) {
|
|
8017
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(clientId)) {
|
|
8018
|
+
throw new Error(`Invalid client-id "${clientId}": use 1-128 chars of letters, digits, dot, underscore, or hyphen.`);
|
|
8019
|
+
}
|
|
8020
|
+
}
|
|
8021
|
+
function hostIsLoopback(hostname) {
|
|
8022
|
+
const h = hostname.toLowerCase();
|
|
8023
|
+
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]";
|
|
8024
|
+
}
|
|
8025
|
+
function redirectLooksLocalhost(uri) {
|
|
8026
|
+
try {
|
|
8027
|
+
return hostIsLoopback(new URL(uri).hostname);
|
|
8028
|
+
}
|
|
8029
|
+
catch {
|
|
8030
|
+
return false;
|
|
8031
|
+
}
|
|
8032
|
+
}
|
|
8033
|
+
function publicUrlIsRemote(env = process.env) {
|
|
8034
|
+
const publicUrl = env.LLM_GATEWAY_PUBLIC_URL;
|
|
8035
|
+
if (!publicUrl)
|
|
8036
|
+
return false;
|
|
8037
|
+
try {
|
|
8038
|
+
return !hostIsLoopback(new URL(publicUrl).hostname);
|
|
8039
|
+
}
|
|
8040
|
+
catch {
|
|
8041
|
+
return false;
|
|
8042
|
+
}
|
|
8043
|
+
}
|
|
8044
|
+
function cliConnectorUrls() {
|
|
8045
|
+
return buildRemoteConnectorUrls({
|
|
8046
|
+
baseOrigin: localBaseUrlForPrint(),
|
|
8047
|
+
mcpPath: process.env.LLM_GATEWAY_HTTP_PATH || "/mcp",
|
|
8048
|
+
oauthEnabled: true,
|
|
8049
|
+
});
|
|
8050
|
+
}
|
|
7984
8051
|
function runOAuthCommand(args) {
|
|
7985
8052
|
const [scope, action] = args;
|
|
7986
8053
|
const config = readMutableGatewayConfig();
|
|
@@ -7992,7 +8059,16 @@ function runOAuthCommand(args) {
|
|
|
7992
8059
|
const clientId = args[2];
|
|
7993
8060
|
if (!clientId)
|
|
7994
8061
|
throw new Error("Usage: llm-cli-gateway oauth client add <client-id> --redirect-uri <uri> [--print-once]");
|
|
8062
|
+
validateCliClientId(clientId);
|
|
7995
8063
|
const redirectUri = requireArg(args, "--redirect-uri");
|
|
8064
|
+
validateCliRedirectUri(redirectUri);
|
|
8065
|
+
if (clients.some(candidate => candidate.client_id === clientId)) {
|
|
8066
|
+
throw new Error(`OAuth client "${clientId}" already exists. Use \`llm-cli-gateway oauth client rotate ${clientId} --print-once\` to issue a new secret, or revoke it first.`);
|
|
8067
|
+
}
|
|
8068
|
+
const warnings = [];
|
|
8069
|
+
if (redirectLooksLocalhost(redirectUri) && publicUrlIsRemote()) {
|
|
8070
|
+
warnings.push("The redirect URI is localhost but a public URL is configured; remote connectors usually need the provider's public callback URL.");
|
|
8071
|
+
}
|
|
7996
8072
|
const secret = generateSecret();
|
|
7997
8073
|
clients.push({
|
|
7998
8074
|
client_id: clientId,
|
|
@@ -8001,18 +8077,26 @@ function runOAuthCommand(args) {
|
|
|
8001
8077
|
scopes: ["mcp"],
|
|
8002
8078
|
});
|
|
8003
8079
|
writeMutableGatewayConfig(config);
|
|
8080
|
+
const printOnce = args.includes("--print-once");
|
|
8081
|
+
const urls = cliConnectorUrls();
|
|
8004
8082
|
printJsonLine({
|
|
8005
8083
|
ok: true,
|
|
8006
8084
|
client_id: clientId,
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8085
|
+
redirect_uri: redirectUri,
|
|
8086
|
+
...(printOnce ? { client_secret: secret, client_secret_copy_once: true } : {}),
|
|
8087
|
+
connector: {
|
|
8088
|
+
mcp_url: urls.mcpUrl,
|
|
8089
|
+
auth_mode: "oauth",
|
|
8090
|
+
authorization_url: urls.authorizationUrl,
|
|
8091
|
+
token_url: urls.tokenUrl,
|
|
8092
|
+
client_id: clientId,
|
|
8012
8093
|
},
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8094
|
+
...(warnings.length ? { warnings } : {}),
|
|
8095
|
+
note: printOnce
|
|
8096
|
+
? "client_secret is shown once and stored only as a hash. Copy it into the connector UI now; it cannot be recovered later. " +
|
|
8097
|
+
OAUTH_RESTART_NOTE
|
|
8098
|
+
: "client secret generated and stored only as a hash. Re-run with --print-once (or `oauth client rotate --print-once`) to reveal a secret. " +
|
|
8099
|
+
OAUTH_RESTART_NOTE,
|
|
8016
8100
|
});
|
|
8017
8101
|
return;
|
|
8018
8102
|
}
|
|
@@ -8022,6 +8106,7 @@ function runOAuthCommand(args) {
|
|
|
8022
8106
|
clients: clients.map(client => ({
|
|
8023
8107
|
client_id: client.client_id,
|
|
8024
8108
|
redirect_uris: client.allowed_redirect_uris ?? [],
|
|
8109
|
+
scopes: client.scopes ?? ["mcp"],
|
|
8025
8110
|
secret_configured: Boolean(client.client_secret_hash),
|
|
8026
8111
|
})),
|
|
8027
8112
|
});
|
|
@@ -8035,11 +8120,21 @@ function runOAuthCommand(args) {
|
|
|
8035
8120
|
const secret = generateSecret();
|
|
8036
8121
|
client.client_secret_hash = hashSecret(secret);
|
|
8037
8122
|
writeMutableGatewayConfig(config);
|
|
8123
|
+
const printOnce = args.includes("--print-once");
|
|
8038
8124
|
printJsonLine({
|
|
8039
8125
|
ok: true,
|
|
8040
8126
|
client_id: clientId,
|
|
8041
|
-
...(
|
|
8042
|
-
|
|
8127
|
+
...(printOnce ? { client_secret: secret, client_secret_copy_once: true } : {}),
|
|
8128
|
+
client: {
|
|
8129
|
+
client_id: client.client_id,
|
|
8130
|
+
redirect_uris: client.allowed_redirect_uris ?? [],
|
|
8131
|
+
secret_configured: true,
|
|
8132
|
+
},
|
|
8133
|
+
note: (printOnce
|
|
8134
|
+
? "New client_secret is shown once; copy it into the connector UI now. "
|
|
8135
|
+
: "New secret generated and stored only as a hash; re-run with --print-once to reveal it. ") +
|
|
8136
|
+
"Future OAuth exchanges use the rotated secret; already-issued opaque access tokens expire by token TTL or server restart. " +
|
|
8137
|
+
OAUTH_RESTART_NOTE,
|
|
8043
8138
|
});
|
|
8044
8139
|
return;
|
|
8045
8140
|
}
|
|
@@ -8050,7 +8145,8 @@ function runOAuthCommand(args) {
|
|
|
8050
8145
|
printJsonLine({
|
|
8051
8146
|
ok: true,
|
|
8052
8147
|
client_id: clientId,
|
|
8053
|
-
note: "Future OAuth exchanges are revoked; already-issued opaque access tokens expire by token TTL or server restart."
|
|
8148
|
+
note: "Future OAuth exchanges are revoked; already-issued opaque access tokens expire by token TTL or server restart. " +
|
|
8149
|
+
OAUTH_RESTART_NOTE,
|
|
8054
8150
|
});
|
|
8055
8151
|
return;
|
|
8056
8152
|
}
|
|
@@ -8068,10 +8164,13 @@ function runOAuthCommand(args) {
|
|
|
8068
8164
|
printJsonLine({
|
|
8069
8165
|
ok: true,
|
|
8070
8166
|
shared_secret_enabled: true,
|
|
8071
|
-
...(args.includes("--print-once")
|
|
8072
|
-
|
|
8073
|
-
|
|
8074
|
-
|
|
8167
|
+
...(args.includes("--print-once")
|
|
8168
|
+
? { shared_secret: secret, shared_secret_copy_once: true }
|
|
8169
|
+
: {}),
|
|
8170
|
+
note: (args.includes("--print-once")
|
|
8171
|
+
? "shared_secret is shown once; it is stored only as a hash. "
|
|
8172
|
+
: "shared secret generated and stored only as a hash; re-run with --print-once to reveal it. ") +
|
|
8173
|
+
OAUTH_RESTART_NOTE,
|
|
8075
8174
|
});
|
|
8076
8175
|
return;
|
|
8077
8176
|
}
|
|
@@ -8086,6 +8185,19 @@ function runOAuthCommand(args) {
|
|
|
8086
8185
|
}
|
|
8087
8186
|
throw new Error("Usage: llm-cli-gateway oauth client|shared-secret ...");
|
|
8088
8187
|
}
|
|
8188
|
+
function runConnectorCommand(args) {
|
|
8189
|
+
const [action] = args;
|
|
8190
|
+
if (action !== "setup") {
|
|
8191
|
+
throw new Error("Usage: llm-cli-gateway connector setup [--client-id <id>] [--include-legacy-no-auth]");
|
|
8192
|
+
}
|
|
8193
|
+
const options = {
|
|
8194
|
+
clientId: argValue(args, "--client-id"),
|
|
8195
|
+
includeLegacyNoAuth: args.includes("--include-legacy-no-auth"),
|
|
8196
|
+
};
|
|
8197
|
+
const packet = gatherConnectorSetupPacket(options);
|
|
8198
|
+
process.stderr.write(renderConnectorSetupSummary(packet) + "\n");
|
|
8199
|
+
printJsonLine(packet);
|
|
8200
|
+
}
|
|
8089
8201
|
function runWorkspaceCommand(args) {
|
|
8090
8202
|
const [action] = args;
|
|
8091
8203
|
if (action === "list") {
|
|
@@ -8148,8 +8260,16 @@ async function main() {
|
|
|
8148
8260
|
"Usage:",
|
|
8149
8261
|
" llm-cli-gateway [doctor --json|contracts --json|--transport=http|--version]",
|
|
8150
8262
|
" llm-cli-gateway oauth client add <id> --redirect-uri <uri> [--print-once]",
|
|
8263
|
+
" llm-cli-gateway connector setup [--client-id <id>] [--include-legacy-no-auth]",
|
|
8151
8264
|
" llm-cli-gateway workspace list|add|create",
|
|
8152
8265
|
"",
|
|
8266
|
+
"Remote connector (recommended OAuth path):",
|
|
8267
|
+
" 1. Set LLM_GATEWAY_PUBLIC_URL to a public https URL (tunnel/reverse proxy).",
|
|
8268
|
+
" 2. oauth client add <id> --redirect-uri <connector-callback> --print-once",
|
|
8269
|
+
" 3. workspace add <alias> <absolute-repo-path> --default",
|
|
8270
|
+
" 4. connector setup # copy-safe fields to paste into the connector UI",
|
|
8271
|
+
" Inspect readiness first: doctor --json -> remote_http_oauth.stage",
|
|
8272
|
+
"",
|
|
8153
8273
|
"Doctor:",
|
|
8154
8274
|
" doctor --json # environment, providers, declared contracts",
|
|
8155
8275
|
" doctor --json --probe-upstream # + expensive installed --help probe for drift",
|
|
@@ -8175,6 +8295,10 @@ async function main() {
|
|
|
8175
8295
|
runOAuthCommand(args.slice(1));
|
|
8176
8296
|
return;
|
|
8177
8297
|
}
|
|
8298
|
+
if (args[0] === "connector") {
|
|
8299
|
+
runConnectorCommand(args.slice(1));
|
|
8300
|
+
return;
|
|
8301
|
+
}
|
|
8178
8302
|
if (args[0] === "workspace") {
|
|
8179
8303
|
runWorkspaceCommand(args.slice(1));
|
|
8180
8304
|
return;
|
package/dist/oauth.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from
|
|
|
2
2
|
import { URLSearchParams } from "node:url";
|
|
3
3
|
import { readCappedRawBody, maxOAuthBodyBytes } from "./request-limits.js";
|
|
4
4
|
import { issueOAuthAccessToken, timingSafeStringEqual, } from "./auth.js";
|
|
5
|
+
import { joinBaseAndPath, OAUTH_AUTHORIZE_PATH, OAUTH_TOKEN_PATH, OAUTH_REGISTER_PATH, OAUTH_PROTECTED_RESOURCE_METADATA_PATH, OAUTH_AUTHORIZATION_SERVER_METADATA_PATH, OPENID_CONFIGURATION_PATH, } from "./remote-url.js";
|
|
5
6
|
export const OAUTH_CODE_TTL_MS = 5 * 60 * 1000;
|
|
6
7
|
const GENERATED_SECRET_BYTES = 32;
|
|
7
8
|
const SCRYPT_N = 32768;
|
|
@@ -227,17 +228,17 @@ export class OAuthServer {
|
|
|
227
228
|
}
|
|
228
229
|
}
|
|
229
230
|
resourceMetadataUrl(baseUrl) {
|
|
230
|
-
return
|
|
231
|
+
return joinBaseAndPath(baseUrl, OAUTH_PROTECTED_RESOURCE_METADATA_PATH);
|
|
231
232
|
}
|
|
232
233
|
isOAuthPath(pathname) {
|
|
233
|
-
return (pathname.startsWith(
|
|
234
|
-
pathname.startsWith(
|
|
235
|
-
pathname ===
|
|
234
|
+
return (pathname.startsWith(OAUTH_PROTECTED_RESOURCE_METADATA_PATH) ||
|
|
235
|
+
pathname.startsWith(OAUTH_AUTHORIZATION_SERVER_METADATA_PATH) ||
|
|
236
|
+
pathname === OPENID_CONFIGURATION_PATH ||
|
|
236
237
|
pathname.startsWith("/oauth/"));
|
|
237
238
|
}
|
|
238
239
|
async handle(ctx) {
|
|
239
240
|
const { req, res, url, baseUrl } = ctx;
|
|
240
|
-
if (url.pathname.startsWith(
|
|
241
|
+
if (url.pathname.startsWith(OAUTH_PROTECTED_RESOURCE_METADATA_PATH)) {
|
|
241
242
|
if (req.method !== "GET") {
|
|
242
243
|
methodNotAllowed(res);
|
|
243
244
|
return true;
|
|
@@ -245,8 +246,8 @@ export class OAuthServer {
|
|
|
245
246
|
jsonResponse(res, 200, this.protectedResourceMetadata(baseUrl));
|
|
246
247
|
return true;
|
|
247
248
|
}
|
|
248
|
-
if (url.pathname.startsWith(
|
|
249
|
-
url.pathname ===
|
|
249
|
+
if (url.pathname.startsWith(OAUTH_AUTHORIZATION_SERVER_METADATA_PATH) ||
|
|
250
|
+
url.pathname === OPENID_CONFIGURATION_PATH) {
|
|
250
251
|
if (req.method !== "GET") {
|
|
251
252
|
methodNotAllowed(res);
|
|
252
253
|
return true;
|
|
@@ -254,15 +255,15 @@ export class OAuthServer {
|
|
|
254
255
|
jsonResponse(res, 200, this.authorizationServerMetadata(baseUrl));
|
|
255
256
|
return true;
|
|
256
257
|
}
|
|
257
|
-
if (url.pathname ===
|
|
258
|
+
if (url.pathname === OAUTH_REGISTER_PATH) {
|
|
258
259
|
await this.handleRegister(req, res);
|
|
259
260
|
return true;
|
|
260
261
|
}
|
|
261
|
-
if (url.pathname ===
|
|
262
|
+
if (url.pathname === OAUTH_AUTHORIZE_PATH) {
|
|
262
263
|
await this.handleAuthorize(req, res);
|
|
263
264
|
return true;
|
|
264
265
|
}
|
|
265
|
-
if (url.pathname ===
|
|
266
|
+
if (url.pathname === OAUTH_TOKEN_PATH) {
|
|
266
267
|
await this.handleToken(req, res);
|
|
267
268
|
return true;
|
|
268
269
|
}
|
|
@@ -270,7 +271,7 @@ export class OAuthServer {
|
|
|
270
271
|
}
|
|
271
272
|
protectedResourceMetadata(baseUrl) {
|
|
272
273
|
return {
|
|
273
|
-
resource:
|
|
274
|
+
resource: joinBaseAndPath(baseUrl, this.opts.protectedPath),
|
|
274
275
|
authorization_servers: [baseUrl],
|
|
275
276
|
scopes_supported: ["mcp", "workspace:admin"],
|
|
276
277
|
bearer_methods_supported: ["header"],
|
|
@@ -279,9 +280,9 @@ export class OAuthServer {
|
|
|
279
280
|
authorizationServerMetadata(baseUrl) {
|
|
280
281
|
return {
|
|
281
282
|
issuer: baseUrl,
|
|
282
|
-
authorization_endpoint:
|
|
283
|
-
token_endpoint:
|
|
284
|
-
registration_endpoint:
|
|
283
|
+
authorization_endpoint: joinBaseAndPath(baseUrl, OAUTH_AUTHORIZE_PATH),
|
|
284
|
+
token_endpoint: joinBaseAndPath(baseUrl, OAUTH_TOKEN_PATH),
|
|
285
|
+
registration_endpoint: joinBaseAndPath(baseUrl, OAUTH_REGISTER_PATH),
|
|
285
286
|
response_types_supported: ["code"],
|
|
286
287
|
grant_types_supported: ["authorization_code"],
|
|
287
288
|
token_endpoint_auth_methods_supported: this.opts.config.allowPublicClients
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const OAUTH_AUTHORIZE_PATH = "/oauth/authorize";
|
|
2
|
+
export declare const OAUTH_TOKEN_PATH = "/oauth/token";
|
|
3
|
+
export declare const OAUTH_REGISTER_PATH = "/oauth/register";
|
|
4
|
+
export declare const OAUTH_PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
5
|
+
export declare const OAUTH_AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
6
|
+
export declare const OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration";
|
|
7
|
+
export declare const DEFAULT_MCP_PATH = "/mcp";
|
|
8
|
+
export declare function joinBaseAndPath(baseOrigin: string, path: string): string;
|
|
9
|
+
export declare function toOrigin(rawUrl: string | null | undefined): string | null;
|
|
10
|
+
export declare function resolveConfiguredRemoteOrigin(opts: {
|
|
11
|
+
issuer?: string | null;
|
|
12
|
+
publicUrl?: string | null;
|
|
13
|
+
}): string | null;
|
|
14
|
+
export interface RemoteConnectorUrls {
|
|
15
|
+
baseOrigin: string | null;
|
|
16
|
+
mcpUrl: string | null;
|
|
17
|
+
issuer: string | null;
|
|
18
|
+
authorizationUrl: string | null;
|
|
19
|
+
tokenUrl: string | null;
|
|
20
|
+
registrationUrl: string | null;
|
|
21
|
+
protectedResourceMetadataUrl: string | null;
|
|
22
|
+
authorizationServerMetadataUrl: string | null;
|
|
23
|
+
}
|
|
24
|
+
export declare function buildRemoteConnectorUrls(opts: {
|
|
25
|
+
baseOrigin: string | null;
|
|
26
|
+
mcpPath?: string;
|
|
27
|
+
oauthEnabled: boolean;
|
|
28
|
+
}): RemoteConnectorUrls;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export const OAUTH_AUTHORIZE_PATH = "/oauth/authorize";
|
|
2
|
+
export const OAUTH_TOKEN_PATH = "/oauth/token";
|
|
3
|
+
export const OAUTH_REGISTER_PATH = "/oauth/register";
|
|
4
|
+
export const OAUTH_PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
5
|
+
export const OAUTH_AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
6
|
+
export const OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration";
|
|
7
|
+
export const DEFAULT_MCP_PATH = "/mcp";
|
|
8
|
+
export function joinBaseAndPath(baseOrigin, path) {
|
|
9
|
+
const trimmedBase = baseOrigin.replace(/\/+$/, "");
|
|
10
|
+
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
11
|
+
return `${trimmedBase}${suffix}`;
|
|
12
|
+
}
|
|
13
|
+
export function toOrigin(rawUrl) {
|
|
14
|
+
if (!rawUrl)
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
return new URL(rawUrl).origin;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function resolveConfiguredRemoteOrigin(opts) {
|
|
24
|
+
if (opts.issuer && opts.issuer !== "auto") {
|
|
25
|
+
const fromIssuer = toOrigin(opts.issuer);
|
|
26
|
+
if (fromIssuer)
|
|
27
|
+
return fromIssuer;
|
|
28
|
+
}
|
|
29
|
+
return toOrigin(opts.publicUrl ?? null);
|
|
30
|
+
}
|
|
31
|
+
export function buildRemoteConnectorUrls(opts) {
|
|
32
|
+
const base = opts.baseOrigin;
|
|
33
|
+
const mcpPath = opts.mcpPath && opts.mcpPath.length > 0 ? opts.mcpPath : DEFAULT_MCP_PATH;
|
|
34
|
+
if (!base) {
|
|
35
|
+
return {
|
|
36
|
+
baseOrigin: null,
|
|
37
|
+
mcpUrl: null,
|
|
38
|
+
issuer: null,
|
|
39
|
+
authorizationUrl: null,
|
|
40
|
+
tokenUrl: null,
|
|
41
|
+
registrationUrl: null,
|
|
42
|
+
protectedResourceMetadataUrl: null,
|
|
43
|
+
authorizationServerMetadataUrl: null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const oauth = opts.oauthEnabled;
|
|
47
|
+
return {
|
|
48
|
+
baseOrigin: base,
|
|
49
|
+
mcpUrl: joinBaseAndPath(base, mcpPath),
|
|
50
|
+
issuer: oauth ? base : null,
|
|
51
|
+
authorizationUrl: oauth ? joinBaseAndPath(base, OAUTH_AUTHORIZE_PATH) : null,
|
|
52
|
+
tokenUrl: oauth ? joinBaseAndPath(base, OAUTH_TOKEN_PATH) : null,
|
|
53
|
+
registrationUrl: oauth ? joinBaseAndPath(base, OAUTH_REGISTER_PATH) : null,
|
|
54
|
+
protectedResourceMetadataUrl: oauth
|
|
55
|
+
? joinBaseAndPath(base, OAUTH_PROTECTED_RESOURCE_METADATA_PATH)
|
|
56
|
+
: null,
|
|
57
|
+
authorizationServerMetadataUrl: oauth
|
|
58
|
+
? joinBaseAndPath(base, OAUTH_AUTHORIZATION_SERVER_METADATA_PATH)
|
|
59
|
+
: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
package/dist/resources.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { callerIsRemote, remoteSafeSession, } from "./session-manager.js";
|
|
1
2
|
import { CLI_TYPES, PROVIDER_TYPES } from "./session-manager.js";
|
|
2
3
|
import { getRequestContext, principalCanAccess, resolveOwnerPrincipal } from "./request-context.js";
|
|
3
4
|
import { getAvailableCliInfo } from "./model-registry.js";
|
|
@@ -240,7 +241,8 @@ export class ResourceProvider {
|
|
|
240
241
|
}
|
|
241
242
|
ownedSessions(sessions) {
|
|
242
243
|
const caller = resolveOwnerPrincipal(getRequestContext());
|
|
243
|
-
|
|
244
|
+
const owned = sessions.filter(s => principalCanAccess(s.ownerPrincipal, caller));
|
|
245
|
+
return callerIsRemote() ? owned.map(remoteSafeSession) : owned;
|
|
244
246
|
}
|
|
245
247
|
async ownedActiveId(provider) {
|
|
246
248
|
const active = await Promise.resolve(this.sessionManager.getActiveSession(provider));
|
|
@@ -23,6 +23,8 @@ export interface SessionStorage {
|
|
|
23
23
|
sessions: Record<string, Session>;
|
|
24
24
|
activeSession: Record<ProviderType, string | null>;
|
|
25
25
|
}
|
|
26
|
+
export declare function remoteSafeSession(session: Session): Session;
|
|
27
|
+
export declare function callerIsRemote(): boolean;
|
|
26
28
|
export type SessionCleanupHook = (session: Session) => void | Promise<void>;
|
|
27
29
|
export declare class FileSessionManager {
|
|
28
30
|
private storagePath;
|
package/dist/session-manager.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { homedir } from "os";
|
|
3
|
-
import { join, dirname } from "path";
|
|
3
|
+
import { join, dirname, relative as pathRelative, isAbsolute as pathIsAbsolute, basename as pathBasename, } from "path";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, openSync, fsyncSync, closeSync, chmodSync, } from "fs";
|
|
5
5
|
import { DEFAULT_SESSION_TTL_SECONDS } from "./config.js";
|
|
6
6
|
import { noopLogger } from "./logger.js";
|
|
@@ -28,6 +28,25 @@ export function defaultSessionDescription(provider) {
|
|
|
28
28
|
return KNOWN_SESSION_DESCRIPTIONS[provider] ?? `${provider} Session`;
|
|
29
29
|
}
|
|
30
30
|
const createEmptyActiveSessions = () => Object.fromEntries(PROVIDER_TYPES.map(provider => [provider, null]));
|
|
31
|
+
export function remoteSafeSession(session) {
|
|
32
|
+
const metadata = session.metadata;
|
|
33
|
+
if (!metadata)
|
|
34
|
+
return session;
|
|
35
|
+
const next = { ...metadata };
|
|
36
|
+
if (typeof next.worktreePath === "string") {
|
|
37
|
+
const root = typeof next.workspaceRoot === "string" ? next.workspaceRoot : undefined;
|
|
38
|
+
const rel = root ? pathRelative(root, next.worktreePath) : "";
|
|
39
|
+
next.worktreePath =
|
|
40
|
+
rel && !rel.startsWith("..") && !pathIsAbsolute(rel) ? rel : pathBasename(next.worktreePath);
|
|
41
|
+
}
|
|
42
|
+
if (typeof next.workspaceRoot === "string")
|
|
43
|
+
delete next.workspaceRoot;
|
|
44
|
+
return { ...session, metadata: next };
|
|
45
|
+
}
|
|
46
|
+
export function callerIsRemote() {
|
|
47
|
+
const ctx = getRequestContext();
|
|
48
|
+
return ctx?.transport === "http" || ctx?.authKind === "oauth";
|
|
49
|
+
}
|
|
31
50
|
export class FileSessionManager {
|
|
32
51
|
storagePath;
|
|
33
52
|
storage = { sessions: {}, activeSession: createEmptyActiveSessions() };
|
|
@@ -60,4 +60,13 @@ export declare function registerExistingWorkspace(input: {
|
|
|
60
60
|
logger?: Logger;
|
|
61
61
|
}): WorkspaceRepo;
|
|
62
62
|
export declare function createTempWorkspaceConfig(contents: string): string;
|
|
63
|
+
export interface RemoteSafeWorkspaceSummary {
|
|
64
|
+
ready: boolean;
|
|
65
|
+
default: string | null;
|
|
66
|
+
aliases: string[];
|
|
67
|
+
repo_count: number;
|
|
68
|
+
allowed_root_count: number;
|
|
69
|
+
}
|
|
70
|
+
export declare function remoteSafeWorkspaceSummary(registry: WorkspaceRegistry): RemoteSafeWorkspaceSummary;
|
|
71
|
+
export declare function describeWorkspaceRemote(repo: WorkspaceRepo): Record<string, unknown>;
|
|
63
72
|
export declare function describeWorkspace(repo: WorkspaceRepo): Record<string, unknown>;
|
|
@@ -350,13 +350,19 @@ export function registerExistingWorkspace(input) {
|
|
|
350
350
|
if (registry.repos.some(repo => repo.alias === alias)) {
|
|
351
351
|
throw new WorkspaceRegistryError(`Workspace alias "${alias}" already exists`);
|
|
352
352
|
}
|
|
353
|
+
const genericDenied = new WorkspaceRegistryError("No allowed root permits registering a Git repository at the requested path.");
|
|
354
|
+
if (!path.isAbsolute(input.repoPath))
|
|
355
|
+
throw genericDenied;
|
|
356
|
+
const candidateReal = path.resolve(expandHome(input.repoPath));
|
|
357
|
+
const permittingRoot = registry.allowedRoots.find(candidate => candidate.allowRegisterExistingGitRepos && isUnder(candidate.path, candidateReal));
|
|
358
|
+
if (!permittingRoot)
|
|
359
|
+
throw genericDenied;
|
|
353
360
|
const real = realExistingPath(input.repoPath);
|
|
354
361
|
if (!isGitRepo(real))
|
|
355
362
|
throw new WorkspaceRegistryError("Existing workspace must be a Git repo");
|
|
356
|
-
const root = registry.allowedRoots.find(candidate => isUnder(candidate.path, real));
|
|
357
|
-
if (!root
|
|
358
|
-
throw
|
|
359
|
-
}
|
|
363
|
+
const root = registry.allowedRoots.find(candidate => candidate.allowRegisterExistingGitRepos && isUnder(candidate.path, real));
|
|
364
|
+
if (!root)
|
|
365
|
+
throw genericDenied;
|
|
360
366
|
const workspaces = (raw.workspaces ?? {});
|
|
361
367
|
const repos = (Array.isArray(workspaces.repos) ? workspaces.repos : []);
|
|
362
368
|
repos.push({
|
|
@@ -389,6 +395,20 @@ export function createTempWorkspaceConfig(contents) {
|
|
|
389
395
|
writeFileSync(configPath, contents, { mode: 0o600 });
|
|
390
396
|
return configPath;
|
|
391
397
|
}
|
|
398
|
+
export function remoteSafeWorkspaceSummary(registry) {
|
|
399
|
+
return {
|
|
400
|
+
ready: registry.defaultAlias !== null || registry.repos.length > 0,
|
|
401
|
+
default: registry.defaultAlias,
|
|
402
|
+
aliases: registry.repos.map(repo => repo.alias),
|
|
403
|
+
repo_count: registry.repos.length,
|
|
404
|
+
allowed_root_count: registry.allowedRoots.length,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
export function describeWorkspaceRemote(repo) {
|
|
408
|
+
const full = describeWorkspace(repo);
|
|
409
|
+
delete full.path;
|
|
410
|
+
return full;
|
|
411
|
+
}
|
|
392
412
|
export function describeWorkspace(repo) {
|
|
393
413
|
let branch = null;
|
|
394
414
|
let dirty = false;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-cli-gateway",
|
|
3
|
-
"version": "2.13.
|
|
3
|
+
"version": "2.13.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "llm-cli-gateway",
|
|
9
|
-
"version": "2.13.
|
|
9
|
+
"version": "2.13.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-cli-gateway",
|
|
3
|
-
"version": "2.13.
|
|
3
|
+
"version": "2.13.2",
|
|
4
4
|
"mcpName": "io.github.verivus-oss/llm-cli-gateway",
|
|
5
5
|
"description": "MCP server providing unified access to Claude Code, Codex, Gemini, Grok, Mistral Vibe, and Devin CLIs with session management, retry logic, async job orchestration, durable job results, and cross-LLM validation.",
|
|
6
6
|
"license": "MIT",
|