llm-cli-gateway 2.13.0 → 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/.agents/skills/multi-llm-review/SKILL.md +111 -16
- package/CHANGELOG.md +69 -0
- package/dist/acp/errors.js +59 -3
- package/dist/api-http.js +16 -2
- 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 +6 -5
- package/dist/index.js +199 -31
- package/dist/oauth.js +20 -15
- 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";
|
|
@@ -21,6 +21,7 @@ import { estimateTokens, optimizePrompt as optimizePromptText, optimizeResponse
|
|
|
21
21
|
import { loadConfig, loadPersistenceConfig, loadCacheAwarenessConfig, loadProvidersConfig, loadAcpConfig, loadLimitsConfig, defaultGatewayConfigPath, isXaiProviderEnabled, enabledApiProviders, minStableTokensForModel, } from "./config.js";
|
|
22
22
|
import { runAcpRequest } from "./acp/runtime.js";
|
|
23
23
|
import { isAcpError } from "./acp/errors.js";
|
|
24
|
+
import { redactSecrets } from "./secret-redaction.js";
|
|
24
25
|
import { createApiProvider, runApiRequest, apiProviderBreakerState, } from "./api-provider.js";
|
|
25
26
|
import { prepareApiRequest, apiProviderCatalogEntry, ApiModelNotAllowedError, } from "./api-request.js";
|
|
26
27
|
import { checkHealth } from "./health.js";
|
|
@@ -40,29 +41,74 @@ import { startHttpGateway } from "./http-transport.js";
|
|
|
40
41
|
import { getRequestContext, resolveOwnerPrincipal, principalCanAccess } from "./request-context.js";
|
|
41
42
|
import { printDoctorJson } from "./doctor.js";
|
|
42
43
|
import { redactDiagnosticUrl } from "./endpoint-exposure.js";
|
|
43
|
-
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";
|
|
44
47
|
import { generateSecret, hashSecret } from "./oauth.js";
|
|
45
48
|
import { registerValidationTools } from "./validation-tools.js";
|
|
46
49
|
import { currentCaller, resolveValidationReceipt } from "./validation-receipt.js";
|
|
47
50
|
import { assertUpstreamCliArgs, assertUpstreamCliEnv, buildProviderSubcommandsCompactCatalog, buildUpstreamContractReport, getCliSubcommandContract, probeInstalledCliContract, serializeCliSubcommandContract, UPSTREAM_CLI_CONTRACTS, } from "./upstream-contracts.js";
|
|
48
51
|
import { buildArgvFromGeneration, deriveZodShapeFromGeneration, GROK_FLAG_GENERATION, GROK_GEN_OUTPUT_FORMAT, GROK_GEN_MAIN, GROK_GEN_PROMPT_FILE, GROK_GEN_SINGLE, GROK_GEN_TAIL, } from "./provider-codegen.js";
|
|
49
52
|
import { entrypointFileURL } from "./entrypoint-url.js";
|
|
50
|
-
const logger = {
|
|
53
|
+
export const logger = {
|
|
51
54
|
info: (message, ...args) => {
|
|
52
|
-
console.error(
|
|
55
|
+
console.error(formatLogLine("INFO", message), ...args.map(sanitizeLogArg));
|
|
53
56
|
},
|
|
54
57
|
warn: (message, ...args) => {
|
|
55
|
-
console.error(
|
|
58
|
+
console.error(formatLogLine("WARN", message), ...args.map(sanitizeLogArg));
|
|
56
59
|
},
|
|
57
60
|
error: (message, ...args) => {
|
|
58
|
-
console.error(
|
|
61
|
+
console.error(formatLogLine("ERROR", message), ...args.map(sanitizeLogArg));
|
|
59
62
|
},
|
|
60
63
|
debug: (message, ...args) => {
|
|
61
64
|
if (process.env.DEBUG) {
|
|
62
|
-
console.error(
|
|
65
|
+
console.error(formatLogLine("DEBUG", message), ...args.map(sanitizeLogArg));
|
|
63
66
|
}
|
|
64
67
|
},
|
|
65
68
|
};
|
|
69
|
+
function formatLogLine(level, message) {
|
|
70
|
+
return `[${level}] ${new Date().toISOString()} - ${redactSecrets(message)}`;
|
|
71
|
+
}
|
|
72
|
+
function sanitizeLogArg(value) {
|
|
73
|
+
return sanitizeLogValue(value, new WeakSet(), 0);
|
|
74
|
+
}
|
|
75
|
+
function sanitizeLogValue(value, seen, depth) {
|
|
76
|
+
if (typeof value === "string") {
|
|
77
|
+
return redactSecrets(value);
|
|
78
|
+
}
|
|
79
|
+
if (value instanceof Error) {
|
|
80
|
+
return sanitizeLogError(value);
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
if (depth >= 4)
|
|
84
|
+
return "[Array]";
|
|
85
|
+
return value.map(item => sanitizeLogValue(item, seen, depth + 1));
|
|
86
|
+
}
|
|
87
|
+
if (value !== null && typeof value === "object") {
|
|
88
|
+
if (seen.has(value))
|
|
89
|
+
return "[Circular]";
|
|
90
|
+
if (depth >= 4)
|
|
91
|
+
return "[Object]";
|
|
92
|
+
seen.add(value);
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
95
|
+
out[key] = sanitizeLogValue(inner, seen, depth + 1);
|
|
96
|
+
}
|
|
97
|
+
seen.delete(value);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
function sanitizeLogError(error) {
|
|
103
|
+
const out = {
|
|
104
|
+
name: redactSecrets(error.name),
|
|
105
|
+
message: redactSecrets(error.message),
|
|
106
|
+
};
|
|
107
|
+
if (error.stack) {
|
|
108
|
+
out.stack = redactSecrets(error.stack);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
66
112
|
function startWindowsBootstrapperSelfHeal() {
|
|
67
113
|
if (process.platform !== "win32")
|
|
68
114
|
return;
|
|
@@ -694,7 +740,10 @@ async function resolveWorkspaceAndWorktreeForRequest(args) {
|
|
|
694
740
|
workspaceAlias: workspace?.alias,
|
|
695
741
|
workspaceRoot: workspace?.root,
|
|
696
742
|
});
|
|
697
|
-
|
|
743
|
+
const displayWorktreePath = isRemoteTransport
|
|
744
|
+
? remoteSafeWorktreePath(resolved.worktreePath, workspace?.root)
|
|
745
|
+
: resolved.worktreePath;
|
|
746
|
+
return { cwd: resolved.cwd, worktreePath: displayWorktreePath, workspace };
|
|
698
747
|
}
|
|
699
748
|
if (workspace && args.sessionId) {
|
|
700
749
|
await Promise.resolve(args.runtime.sessionManager.updateSessionMetadata(args.sessionId, {
|
|
@@ -707,6 +756,16 @@ async function resolveWorkspaceAndWorktreeForRequest(args) {
|
|
|
707
756
|
export function formatWorktreePrefix(worktreePath) {
|
|
708
757
|
return worktreePath ? `[gateway] worktree=${worktreePath}\n` : "";
|
|
709
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
|
+
}
|
|
710
769
|
function workspaceAdminEnabled() {
|
|
711
770
|
const scopes = getRequestContext()?.authScopes ?? [];
|
|
712
771
|
return process.env.LLM_GATEWAY_WORKSPACE_ADMIN === "1" && scopes.includes("workspace:admin");
|
|
@@ -736,10 +795,9 @@ function registerWorkspaceTools(server, runtime) {
|
|
|
736
795
|
success: true,
|
|
737
796
|
enabled: registry.enabled,
|
|
738
797
|
default: registry.defaultAlias,
|
|
739
|
-
workspaces: registry.repos.map(
|
|
798
|
+
workspaces: registry.repos.map(describeWorkspaceRemote),
|
|
740
799
|
allowed_roots: registry.allowedRoots.map(root => ({
|
|
741
800
|
alias: root.alias,
|
|
742
|
-
path: root.path,
|
|
743
801
|
allow_register_existing_git_repos: root.allowRegisterExistingGitRepos,
|
|
744
802
|
allow_create_directories: root.allowCreateDirectories,
|
|
745
803
|
allow_init_git_repos: root.allowInitGitRepos,
|
|
@@ -768,7 +826,10 @@ function registerWorkspaceTools(server, runtime) {
|
|
|
768
826
|
content: [
|
|
769
827
|
{
|
|
770
828
|
type: "text",
|
|
771
|
-
text: JSON.stringify({
|
|
829
|
+
text: JSON.stringify({
|
|
830
|
+
success: true,
|
|
831
|
+
workspace: describeWorkspaceRemote(getWorkspace(registry, alias)),
|
|
832
|
+
}, null, 2),
|
|
772
833
|
},
|
|
773
834
|
],
|
|
774
835
|
};
|
|
@@ -807,7 +868,7 @@ function registerWorkspaceTools(server, runtime) {
|
|
|
807
868
|
content: [
|
|
808
869
|
{
|
|
809
870
|
type: "text",
|
|
810
|
-
text: JSON.stringify({ success: true, workspace:
|
|
871
|
+
text: JSON.stringify({ success: true, workspace: describeWorkspaceRemote(repo) }, null, 2),
|
|
811
872
|
},
|
|
812
873
|
],
|
|
813
874
|
};
|
|
@@ -845,7 +906,7 @@ function registerWorkspaceTools(server, runtime) {
|
|
|
845
906
|
content: [
|
|
846
907
|
{
|
|
847
908
|
type: "text",
|
|
848
|
-
text: JSON.stringify({ success: true, workspace:
|
|
909
|
+
text: JSON.stringify({ success: true, workspace: describeWorkspaceRemote(repo) }, null, 2),
|
|
849
910
|
},
|
|
850
911
|
],
|
|
851
912
|
};
|
|
@@ -2415,7 +2476,7 @@ export async function handleGrokApiRequest(deps, params) {
|
|
|
2415
2476
|
}
|
|
2416
2477
|
const apiKey = process.env[xaiConfig.apiKeyEnv]?.trim();
|
|
2417
2478
|
if (!apiKey) {
|
|
2418
|
-
return createErrorResponse("grok_api_request", 1, "", corrId, new Error(
|
|
2479
|
+
return createErrorResponse("grok_api_request", 1, "", corrId, new Error("xAI API key is not configured"));
|
|
2419
2480
|
}
|
|
2420
2481
|
safeFlightStart({
|
|
2421
2482
|
correlationId: corrId,
|
|
@@ -7745,7 +7806,7 @@ export function createGatewayServer(deps = {}) {
|
|
|
7745
7806
|
text: JSON.stringify({
|
|
7746
7807
|
success: true,
|
|
7747
7808
|
session: {
|
|
7748
|
-
...session,
|
|
7809
|
+
...(callerIsRemote() ? remoteSafeSession(session) : session),
|
|
7749
7810
|
isActive: activeSession?.id === session.id,
|
|
7750
7811
|
...(cacheState ? { cacheState } : {}),
|
|
7751
7812
|
},
|
|
@@ -7937,6 +7998,56 @@ function localBaseUrlForPrint() {
|
|
|
7937
7998
|
function printJsonLine(value) {
|
|
7938
7999
|
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
7939
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
|
+
}
|
|
7940
8051
|
function runOAuthCommand(args) {
|
|
7941
8052
|
const [scope, action] = args;
|
|
7942
8053
|
const config = readMutableGatewayConfig();
|
|
@@ -7948,7 +8059,16 @@ function runOAuthCommand(args) {
|
|
|
7948
8059
|
const clientId = args[2];
|
|
7949
8060
|
if (!clientId)
|
|
7950
8061
|
throw new Error("Usage: llm-cli-gateway oauth client add <client-id> --redirect-uri <uri> [--print-once]");
|
|
8062
|
+
validateCliClientId(clientId);
|
|
7951
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
|
+
}
|
|
7952
8072
|
const secret = generateSecret();
|
|
7953
8073
|
clients.push({
|
|
7954
8074
|
client_id: clientId,
|
|
@@ -7957,18 +8077,26 @@ function runOAuthCommand(args) {
|
|
|
7957
8077
|
scopes: ["mcp"],
|
|
7958
8078
|
});
|
|
7959
8079
|
writeMutableGatewayConfig(config);
|
|
8080
|
+
const printOnce = args.includes("--print-once");
|
|
8081
|
+
const urls = cliConnectorUrls();
|
|
7960
8082
|
printJsonLine({
|
|
7961
8083
|
ok: true,
|
|
7962
8084
|
client_id: clientId,
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
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,
|
|
7968
8093
|
},
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
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,
|
|
7972
8100
|
});
|
|
7973
8101
|
return;
|
|
7974
8102
|
}
|
|
@@ -7978,6 +8106,7 @@ function runOAuthCommand(args) {
|
|
|
7978
8106
|
clients: clients.map(client => ({
|
|
7979
8107
|
client_id: client.client_id,
|
|
7980
8108
|
redirect_uris: client.allowed_redirect_uris ?? [],
|
|
8109
|
+
scopes: client.scopes ?? ["mcp"],
|
|
7981
8110
|
secret_configured: Boolean(client.client_secret_hash),
|
|
7982
8111
|
})),
|
|
7983
8112
|
});
|
|
@@ -7991,11 +8120,21 @@ function runOAuthCommand(args) {
|
|
|
7991
8120
|
const secret = generateSecret();
|
|
7992
8121
|
client.client_secret_hash = hashSecret(secret);
|
|
7993
8122
|
writeMutableGatewayConfig(config);
|
|
8123
|
+
const printOnce = args.includes("--print-once");
|
|
7994
8124
|
printJsonLine({
|
|
7995
8125
|
ok: true,
|
|
7996
8126
|
client_id: clientId,
|
|
7997
|
-
...(
|
|
7998
|
-
|
|
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,
|
|
7999
8138
|
});
|
|
8000
8139
|
return;
|
|
8001
8140
|
}
|
|
@@ -8006,7 +8145,8 @@ function runOAuthCommand(args) {
|
|
|
8006
8145
|
printJsonLine({
|
|
8007
8146
|
ok: true,
|
|
8008
8147
|
client_id: clientId,
|
|
8009
|
-
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,
|
|
8010
8150
|
});
|
|
8011
8151
|
return;
|
|
8012
8152
|
}
|
|
@@ -8024,10 +8164,13 @@ function runOAuthCommand(args) {
|
|
|
8024
8164
|
printJsonLine({
|
|
8025
8165
|
ok: true,
|
|
8026
8166
|
shared_secret_enabled: true,
|
|
8027
|
-
...(args.includes("--print-once")
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
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,
|
|
8031
8174
|
});
|
|
8032
8175
|
return;
|
|
8033
8176
|
}
|
|
@@ -8042,6 +8185,19 @@ function runOAuthCommand(args) {
|
|
|
8042
8185
|
}
|
|
8043
8186
|
throw new Error("Usage: llm-cli-gateway oauth client|shared-secret ...");
|
|
8044
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
|
+
}
|
|
8045
8201
|
function runWorkspaceCommand(args) {
|
|
8046
8202
|
const [action] = args;
|
|
8047
8203
|
if (action === "list") {
|
|
@@ -8104,8 +8260,16 @@ async function main() {
|
|
|
8104
8260
|
"Usage:",
|
|
8105
8261
|
" llm-cli-gateway [doctor --json|contracts --json|--transport=http|--version]",
|
|
8106
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]",
|
|
8107
8264
|
" llm-cli-gateway workspace list|add|create",
|
|
8108
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
|
+
"",
|
|
8109
8273
|
"Doctor:",
|
|
8110
8274
|
" doctor --json # environment, providers, declared contracts",
|
|
8111
8275
|
" doctor --json --probe-upstream # + expensive installed --help probe for drift",
|
|
@@ -8131,6 +8295,10 @@ async function main() {
|
|
|
8131
8295
|
runOAuthCommand(args.slice(1));
|
|
8132
8296
|
return;
|
|
8133
8297
|
}
|
|
8298
|
+
if (args[0] === "connector") {
|
|
8299
|
+
runConnectorCommand(args.slice(1));
|
|
8300
|
+
return;
|
|
8301
|
+
}
|
|
8134
8302
|
if (args[0] === "workspace") {
|
|
8135
8303
|
runWorkspaceCommand(args.slice(1));
|
|
8136
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;
|
|
@@ -77,6 +78,10 @@ function readCookie(req, name) {
|
|
|
77
78
|
}
|
|
78
79
|
return null;
|
|
79
80
|
}
|
|
81
|
+
function oauthCsrfCookie(csrf) {
|
|
82
|
+
const maxAgeSeconds = Math.floor(OAUTH_CODE_TTL_MS / 1000);
|
|
83
|
+
return `gw_oauth_csrf=${csrf}; HttpOnly; Secure; SameSite=Lax; Path=/oauth; Max-Age=${maxAgeSeconds}`;
|
|
84
|
+
}
|
|
80
85
|
function methodNotAllowed(res) {
|
|
81
86
|
res.writeHead(405, { allow: "GET, POST", "content-type": "application/json" });
|
|
82
87
|
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
@@ -223,17 +228,17 @@ export class OAuthServer {
|
|
|
223
228
|
}
|
|
224
229
|
}
|
|
225
230
|
resourceMetadataUrl(baseUrl) {
|
|
226
|
-
return
|
|
231
|
+
return joinBaseAndPath(baseUrl, OAUTH_PROTECTED_RESOURCE_METADATA_PATH);
|
|
227
232
|
}
|
|
228
233
|
isOAuthPath(pathname) {
|
|
229
|
-
return (pathname.startsWith(
|
|
230
|
-
pathname.startsWith(
|
|
231
|
-
pathname ===
|
|
234
|
+
return (pathname.startsWith(OAUTH_PROTECTED_RESOURCE_METADATA_PATH) ||
|
|
235
|
+
pathname.startsWith(OAUTH_AUTHORIZATION_SERVER_METADATA_PATH) ||
|
|
236
|
+
pathname === OPENID_CONFIGURATION_PATH ||
|
|
232
237
|
pathname.startsWith("/oauth/"));
|
|
233
238
|
}
|
|
234
239
|
async handle(ctx) {
|
|
235
240
|
const { req, res, url, baseUrl } = ctx;
|
|
236
|
-
if (url.pathname.startsWith(
|
|
241
|
+
if (url.pathname.startsWith(OAUTH_PROTECTED_RESOURCE_METADATA_PATH)) {
|
|
237
242
|
if (req.method !== "GET") {
|
|
238
243
|
methodNotAllowed(res);
|
|
239
244
|
return true;
|
|
@@ -241,8 +246,8 @@ export class OAuthServer {
|
|
|
241
246
|
jsonResponse(res, 200, this.protectedResourceMetadata(baseUrl));
|
|
242
247
|
return true;
|
|
243
248
|
}
|
|
244
|
-
if (url.pathname.startsWith(
|
|
245
|
-
url.pathname ===
|
|
249
|
+
if (url.pathname.startsWith(OAUTH_AUTHORIZATION_SERVER_METADATA_PATH) ||
|
|
250
|
+
url.pathname === OPENID_CONFIGURATION_PATH) {
|
|
246
251
|
if (req.method !== "GET") {
|
|
247
252
|
methodNotAllowed(res);
|
|
248
253
|
return true;
|
|
@@ -250,15 +255,15 @@ export class OAuthServer {
|
|
|
250
255
|
jsonResponse(res, 200, this.authorizationServerMetadata(baseUrl));
|
|
251
256
|
return true;
|
|
252
257
|
}
|
|
253
|
-
if (url.pathname ===
|
|
258
|
+
if (url.pathname === OAUTH_REGISTER_PATH) {
|
|
254
259
|
await this.handleRegister(req, res);
|
|
255
260
|
return true;
|
|
256
261
|
}
|
|
257
|
-
if (url.pathname ===
|
|
262
|
+
if (url.pathname === OAUTH_AUTHORIZE_PATH) {
|
|
258
263
|
await this.handleAuthorize(req, res);
|
|
259
264
|
return true;
|
|
260
265
|
}
|
|
261
|
-
if (url.pathname ===
|
|
266
|
+
if (url.pathname === OAUTH_TOKEN_PATH) {
|
|
262
267
|
await this.handleToken(req, res);
|
|
263
268
|
return true;
|
|
264
269
|
}
|
|
@@ -266,7 +271,7 @@ export class OAuthServer {
|
|
|
266
271
|
}
|
|
267
272
|
protectedResourceMetadata(baseUrl) {
|
|
268
273
|
return {
|
|
269
|
-
resource:
|
|
274
|
+
resource: joinBaseAndPath(baseUrl, this.opts.protectedPath),
|
|
270
275
|
authorization_servers: [baseUrl],
|
|
271
276
|
scopes_supported: ["mcp", "workspace:admin"],
|
|
272
277
|
bearer_methods_supported: ["header"],
|
|
@@ -275,9 +280,9 @@ export class OAuthServer {
|
|
|
275
280
|
authorizationServerMetadata(baseUrl) {
|
|
276
281
|
return {
|
|
277
282
|
issuer: baseUrl,
|
|
278
|
-
authorization_endpoint:
|
|
279
|
-
token_endpoint:
|
|
280
|
-
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),
|
|
281
286
|
response_types_supported: ["code"],
|
|
282
287
|
grant_types_supported: ["authorization_code"],
|
|
283
288
|
token_endpoint_auth_methods_supported: this.opts.config.allowPublicClients
|
|
@@ -466,7 +471,7 @@ ${errorBlock}
|
|
|
466
471
|
<p class="muted">Only approve if you initiated this connection.</p></div></body></html>`;
|
|
467
472
|
res.writeHead(200, {
|
|
468
473
|
"content-type": "text/html; charset=utf-8",
|
|
469
|
-
"set-cookie":
|
|
474
|
+
"set-cookie": oauthCsrfCookie(csrf),
|
|
470
475
|
"cache-control": "no-store",
|
|
471
476
|
});
|
|
472
477
|
res.end(html);
|
|
@@ -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() };
|