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/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 { 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";
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
- 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 };
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(describeWorkspace),
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({ 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),
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: describeWorkspace(repo) }, null, 2),
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: describeWorkspace(repo) }, null, 2),
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
- ...(args.includes("--print-once") ? { client_secret: secret } : {}),
8008
- oauth: {
8009
- issuer: localBaseUrlForPrint(),
8010
- authorization_url: `${localBaseUrlForPrint()}/oauth/authorize`,
8011
- 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,
8012
8093
  },
8013
- note: args.includes("--print-once")
8014
- ? "client_secret is shown once; it is stored only as a hash."
8015
- : "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,
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
- ...(args.includes("--print-once") ? { client_secret: secret } : {}),
8042
- 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,
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") ? { shared_secret: secret } : {}),
8072
- note: args.includes("--print-once")
8073
- ? "shared_secret is shown once; it is stored only as a hash."
8074
- : "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,
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 `${baseUrl}/.well-known/oauth-protected-resource`;
231
+ return joinBaseAndPath(baseUrl, OAUTH_PROTECTED_RESOURCE_METADATA_PATH);
231
232
  }
232
233
  isOAuthPath(pathname) {
233
- return (pathname.startsWith("/.well-known/oauth-protected-resource") ||
234
- pathname.startsWith("/.well-known/oauth-authorization-server") ||
235
- 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 ||
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("/.well-known/oauth-protected-resource")) {
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("/.well-known/oauth-authorization-server") ||
249
- url.pathname === "/.well-known/openid-configuration") {
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 === "/oauth/register") {
258
+ if (url.pathname === OAUTH_REGISTER_PATH) {
258
259
  await this.handleRegister(req, res);
259
260
  return true;
260
261
  }
261
- if (url.pathname === "/oauth/authorize") {
262
+ if (url.pathname === OAUTH_AUTHORIZE_PATH) {
262
263
  await this.handleAuthorize(req, res);
263
264
  return true;
264
265
  }
265
- if (url.pathname === "/oauth/token") {
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: `${baseUrl}${this.opts.protectedPath}`,
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: `${baseUrl}/oauth/authorize`,
283
- token_endpoint: `${baseUrl}/oauth/token`,
284
- 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),
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
- 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() };
@@ -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?.allowRegisterExistingGitRepos) {
358
- throw new WorkspaceRegistryError("No allowed root permits registering this Git repo");
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;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "2.13.1",
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.1",
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.1",
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",