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/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 { createWorkspace, describeWorkspace, getWorkspace, loadWorkspaceRegistry, registerExistingWorkspace, resolveWorkspaceForProvider, validatePathInsideWorkspace, } from "./workspace-registry.js";
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(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
55
+ console.error(formatLogLine("INFO", message), ...args.map(sanitizeLogArg));
53
56
  },
54
57
  warn: (message, ...args) => {
55
- console.error(`[WARN] ${new Date().toISOString()} - ${message}`, ...args);
58
+ console.error(formatLogLine("WARN", message), ...args.map(sanitizeLogArg));
56
59
  },
57
60
  error: (message, ...args) => {
58
- console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
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(`[DEBUG] ${new Date().toISOString()} - ${message}`, ...args);
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
- return { cwd: resolved.cwd, worktreePath: resolved.worktreePath, workspace };
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(describeWorkspace),
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({ success: true, workspace: describeWorkspace(getWorkspace(registry, alias)) }, null, 2),
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: describeWorkspace(repo) }, null, 2),
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: describeWorkspace(repo) }, null, 2),
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(`xAI API key env var ${xaiConfig.apiKeyEnv} is not set`));
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
- ...(args.includes("--print-once") ? { client_secret: secret } : {}),
7964
- oauth: {
7965
- issuer: localBaseUrlForPrint(),
7966
- authorization_url: `${localBaseUrlForPrint()}/oauth/authorize`,
7967
- token_url: `${localBaseUrlForPrint()}/oauth/token`,
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
- note: args.includes("--print-once")
7970
- ? "client_secret is shown once; it is stored only as a hash."
7971
- : "client secret generated and stored only as a hash; rerun rotate --print-once if needed.",
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
- ...(args.includes("--print-once") ? { client_secret: secret } : {}),
7998
- note: "Future OAuth exchanges use the rotated secret; already-issued opaque access tokens expire by token TTL or server restart.",
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") ? { shared_secret: secret } : {}),
8028
- note: args.includes("--print-once")
8029
- ? "shared_secret is shown once; it is stored only as a hash."
8030
- : "shared secret generated and stored only as a hash.",
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 `${baseUrl}/.well-known/oauth-protected-resource`;
231
+ return joinBaseAndPath(baseUrl, OAUTH_PROTECTED_RESOURCE_METADATA_PATH);
227
232
  }
228
233
  isOAuthPath(pathname) {
229
- return (pathname.startsWith("/.well-known/oauth-protected-resource") ||
230
- pathname.startsWith("/.well-known/oauth-authorization-server") ||
231
- pathname === "/.well-known/openid-configuration" ||
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("/.well-known/oauth-protected-resource")) {
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("/.well-known/oauth-authorization-server") ||
245
- url.pathname === "/.well-known/openid-configuration") {
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 === "/oauth/register") {
258
+ if (url.pathname === OAUTH_REGISTER_PATH) {
254
259
  await this.handleRegister(req, res);
255
260
  return true;
256
261
  }
257
- if (url.pathname === "/oauth/authorize") {
262
+ if (url.pathname === OAUTH_AUTHORIZE_PATH) {
258
263
  await this.handleAuthorize(req, res);
259
264
  return true;
260
265
  }
261
- if (url.pathname === "/oauth/token") {
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: `${baseUrl}${this.opts.protectedPath}`,
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: `${baseUrl}/oauth/authorize`,
279
- token_endpoint: `${baseUrl}/oauth/token`,
280
- registration_endpoint: `${baseUrl}/oauth/register`,
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": `gw_oauth_csrf=${csrf}; HttpOnly; SameSite=Lax; Path=/oauth`,
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
- return sessions.filter(s => principalCanAccess(s.ownerPrincipal, caller));
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;
@@ -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() };