Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,44 @@ public void ConnectCommand_VSCodeCredentialOption_DoesNotProduceUnknownOptionDia
Assert.DoesNotContain(model.Diagnostics, diagnostic => diagnostic.Code == "SEM002");
}

[Fact]
public async Task ConnectCommand_EmulatorOption_BindsFlag()
{
var command = await BindConnectCommandAsync("connect --emulator");

Assert.Null(command.ConnectionString);
Assert.True(command.Emulator);
}

[Fact]
public async Task ConnectCommand_EmulatorShortOption_BindsFlag()
{
var command = await BindConnectCommandAsync("connect -e");

Assert.True(command.Emulator);
}

[Fact]
public async Task ConnectCommand_EmulatorWithExplicitEndpoint_BindsBoth()
{
var command = await BindConnectCommandAsync("connect --emulator https://localhost:9000/");

Assert.Equal("https://localhost:9000/", command.ConnectionString);
Assert.True(command.Emulator);
}

[Fact]
public async Task ConnectAsync_EmulatorAgainstNonLocalEndpoint_Throws()
{
using var shell = ShellInterpreter.CreateInstance();

var ex = await Assert.ThrowsAsync<ShellException>(() => shell.ConnectAsync(
"https://contoso.documents.azure.com:443/",
forceEmulator: true,
token: CancellationToken.None));
Assert.Contains("emulator", ex.Message, StringComparison.OrdinalIgnoreCase);
}

private static async Task<ConnectCommand> BindConnectCommandAsync(string commandText)
{
var parser = new StatementParser(commandText);
Expand Down
14 changes: 10 additions & 4 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace Azure.Data.Cosmos.Shell.Commands;
[CosmosCommand("connect")]
[CosmosExample("connect", Description = "Show current connection information and mode")]
[CosmosExample("connect \"AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=mykey;\"", Description = "Connect using connection string with account key")]
[CosmosExample("connect --emulator", Description = "Connect to the local Cosmos DB Emulator on https://localhost:8081 (probes HTTPS then HTTP)")]
Comment thread
mkrueger marked this conversation as resolved.
Outdated
[CosmosExample("connect https://localhost:8081", Description = "Connect to the local Cosmos DB Emulator (uses well-known key and gateway mode)")]
[CosmosExample("connect https://myaccount.documents.azure.com:443/ -hint=user@contoso.com", Description = "Connect using Entra ID authentication with login hint")]
[CosmosExample("connect https://myaccount.documents.azure.com:443/ -tenant=<tenant-id> -mode=gateway", Description = "Connect using Entra ID with gateway connection mode")]
Expand Down Expand Up @@ -46,10 +47,13 @@ internal partial class ConnectCommand : CosmosCommand
[CosmosOption("vscode-credential", "connect-vscode-credential", Hidden = true)]
public bool UseVSCodeCredential { get; init; }

[CosmosOption("emulator", "e")]
public bool Emulator { get; init; }

public async override Task<CommandState> ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token)
{
// If no connection string provided, show current connection info
if (this.ConnectionString is null)
// If no connection string and not using --emulator, show current connection info
if (this.ConnectionString is null && !this.Emulator)
{
return await PrintConnectionInfoAsync(shell, commandState, token);
}
Expand Down Expand Up @@ -85,12 +89,14 @@ public async override Task<CommandState> ExecuteAsync(ShellInterpreter shell, Co

try
{
await shell.ConnectAsync(this.ConnectionString, this.LoginHint, connectionMode, tenantId: this.TenantId, authorityHost: this.AuthorityHost, managedIdentityClientId: this.ManagedIdentityClientId, useVSCodeCredential: this.UseVSCodeCredential, token: token);
await shell.ConnectAsync(this.ConnectionString, this.LoginHint, connectionMode, tenantId: this.TenantId, authorityHost: this.AuthorityHost, managedIdentityClientId: this.ManagedIdentityClientId, useVSCodeCredential: this.UseVSCodeCredential, forceEmulator: this.Emulator, token: token);
var returnState = new CommandState
{
IsPrinted = true,
};
var endpoint = ParsedDocDBConnectionString.ExtractEndpoint(this.ConnectionString);
var endpoint = this.ConnectionString is null
? null
: ParsedDocDBConnectionString.ExtractEndpoint(this.ConnectionString);
var resultElement = JsonSerializer.SerializeToElement(new Dictionary<string, string?>
{
["connected state"] = endpoint,
Expand Down
148 changes: 129 additions & 19 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ internal async Task<CommandState> RunCommandAsync(CommandState currentState, str
return currentState;
}

internal async Task ConnectAsync(string connectionString, string? loginHint = null, ConnectionMode? mode = null, string? tenantId = null, string? authorityHost = null, string? managedIdentityClientId = null, bool useVSCodeCredential = false, CancellationToken token = default)
internal async Task ConnectAsync(string? connectionString, string? loginHint = null, ConnectionMode? mode = null, string? tenantId = null, string? authorityHost = null, string? managedIdentityClientId = null, bool useVSCodeCredential = false, bool forceEmulator = false, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();

Expand All @@ -583,8 +583,25 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu
CosmosClient? client = null;

// Step 1: Resolve account key (from connection string, env variable, or emulator well-known key)
bool isEmulator = ParsedDocDBConnectionString.IsLocalEmulatorEndpoint(connectionString);
if (isEmulator)
if (forceEmulator)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
connectionString = "https://localhost:8081/";
}
else if (!ParsedDocDBConnectionString.IsLocalEmulatorEndpoint(connectionString))
{
throw new ShellException(MessageService.GetString("command-connect-emulator-non_local"));
}
Comment thread
mkrueger marked this conversation as resolved.
}

if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ShellException(MessageService.GetString("command-connect-error-no_endpoint"));
}

bool isEmulator = forceEmulator || ParsedDocDBConnectionString.IsLocalEmulatorEndpoint(connectionString);
if (isEmulator && !forceEmulator)
{
WriteLine(MessageService.GetString("command-connect-emulator-detected"));
}
Expand Down Expand Up @@ -625,26 +642,21 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu
{
WriteLine(MessageService.GetString("shell-connect-key-auth"));
var keyMode = mode ?? (isEmulator ? ConnectionMode.Gateway : ConnectionMode.Direct);
var keyOptions = CreateClientOptions(connectionString, keyMode);
client = new CosmosClient(connectionString, keyOptions);

AccountProperties keyProps;
try
{
keyProps = await ReadAccountAsync(client, token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
client.Dispose();
throw;
}
catch (Exception ex)
(CosmosClient connectedClient, AccountProperties keyProps, string finalEndpoint) = await ConnectWithAccountKeyAsync(
connectionString,
keyMode,
isEmulator,
token);
client = connectedClient;

WriteLine(MessageService.GetArgsString("command-connect-connected", "account", keyProps.Id));

if (isEmulator)
{
client.Dispose();
throw new ShellException(MessageService.GetString("error-connection_failed"), ex);
ReportEmulatorProtocol(finalEndpoint);
}

WriteLine(MessageService.GetArgsString("command-connect-connected", "account", keyProps.Id));
this.Connect(client);
return;
}
Expand Down Expand Up @@ -903,6 +915,104 @@ private static async Task<AccountProperties> ReadAccountAsync(CosmosClient clien
return await client.ReadAccountAsync().WaitAsync(token);
}

/// <summary>
/// Connects with an account key, with an emulator-only HTTPS to HTTP fallback when the
/// underlying TLS handshake fails. Returns the live client, the account properties, and
/// the endpoint that was actually used.
/// </summary>
private static async Task<(CosmosClient Client, AccountProperties Properties, string Endpoint)> ConnectWithAccountKeyAsync(
string connectionString,
ConnectionMode keyMode,
bool isEmulator,
CancellationToken token)
{
var endpoint = ParsedDocDBConnectionString.ExtractEndpoint(connectionString);
var keyOptions = CreateClientOptions(connectionString, keyMode);
var client = new CosmosClient(connectionString, keyOptions);

try
{
var properties = await ReadAccountAsync(client, token);
return (client, properties, endpoint);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
client.Dispose();
throw;
}
catch (Exception ex)
{
client.Dispose();

if (isEmulator && IsTlsHandshakeFailure(ex) &&
Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) &&
endpointUri.Scheme == Uri.UriSchemeHttps)
{
var httpEndpoint = new UriBuilder(endpointUri) { Scheme = Uri.UriSchemeHttp, Port = endpointUri.Port }.Uri.ToString();
WriteLine(MessageService.GetString("command-connect-emulator-https_failed"));

var fallbackConnectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString(
httpEndpoint,
ParsedDocDBConnectionString.TryParseDocDBConnectionString(connectionString, out var parsed) ? parsed?.MasterKey : null);
var fallbackOptions = CreateClientOptions(fallbackConnectionString, keyMode);
var fallbackClient = new CosmosClient(fallbackConnectionString, fallbackOptions);
try
{
var fallbackProperties = await ReadAccountAsync(fallbackClient, token);
return (fallbackClient, fallbackProperties, httpEndpoint);
}
catch
{
fallbackClient.Dispose();
Comment thread
mkrueger marked this conversation as resolved.
Outdated
throw new ShellException(MessageService.GetString("error-connection_failed"), ex);
Comment thread
mkrueger marked this conversation as resolved.
Outdated
}
}

throw new ShellException(MessageService.GetString("error-connection_failed"), ex);
}
}
Comment thread
mkrueger marked this conversation as resolved.

/// <summary>
/// Detects TLS handshake / certificate-validation failures in the exception chain. Used to
/// decide whether an emulator HTTPS attempt should fall back to HTTP.
/// </summary>
private static bool IsTlsHandshakeFailure(Exception ex)
{
for (var current = ex; current != null; current = current.InnerException)
{
if (current is System.Security.Authentication.AuthenticationException)
{
return true;
}

if (current is System.Net.Http.HttpRequestException &&
current.Message.Contains("SSL", StringComparison.OrdinalIgnoreCase))
{
return true;
}
Comment thread
mkrueger marked this conversation as resolved.
}

return false;
}

private static void ReportEmulatorProtocol(string endpoint)
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
return;
}

if (uri.Scheme == Uri.UriSchemeHttps)
{
WriteLine(MessageService.GetArgsString("command-connect-emulator-using_https", "endpoint", endpoint));
}
else
{
WriteLine(MessageService.GetArgsString("command-connect-emulator-using_http", "endpoint", endpoint));
WriteLine(MessageService.GetString("command-connect-emulator-http_tip"));
}
}

/// <summary>
/// Connects to a client & disposes old state.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public class LocalizableSentenceBuilder : SentenceBuilder

public static string ConnectVSCodeCredential => MessageService.GetString("help-ConnectVSCodeCredential");

public static string ConnectEmulator => MessageService.GetString("help-ConnectEmulator");

public static string Command => MessageService.GetString("help-cmd");

public static string EnableMcpServer => MessageService.GetString("help-EnableMcpServer");
Expand Down
6 changes: 5 additions & 1 deletion CosmosDBShell/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public static async Task Main(string[] args)
};
ShellInterpreter.Instance.Options = o;

if (o.ConnectionString != null)
if (o.ConnectionString != null || o.ConnectEmulator)
{
using var connectTokenSource = ShellInterpreter.UserCancellationTokenSource;
var connectToken = connectTokenSource.Token;
Expand All @@ -127,6 +127,7 @@ await ShellInterpreter.Instance.ConnectAsync(
authorityHost: o.ConnectAuthorityHost,
managedIdentityClientId: o.ConnectManagedIdentity,
useVSCodeCredential: o.ConnectVSCodeCredential,
forceEmulator: o.ConnectEmulator,
token: connectToken);
}
catch (OperationCanceledException) when (connectToken.IsCancellationRequested)
Expand Down Expand Up @@ -352,6 +353,9 @@ public class CosmosShellOptions
[Option("connect-vscode-credential", Required = false, HelpText = "ConnectVSCodeCredential", ResourceType = typeof(LocalizableSentenceBuilder), Hidden = true)]
public bool ConnectVSCodeCredential { get; set; }

[Option("connect-emulator", Required = false, HelpText = "ConnectEmulator", ResourceType = typeof(LocalizableSentenceBuilder))]
public bool ConnectEmulator { get; set; }

[Option("mcp", Required = false, HelpText = "McpPort", ResourceType = typeof(LocalizableSentenceBuilder))]
public int? McpPort { get; set; }

Expand Down
7 changes: 7 additions & 0 deletions CosmosDBShell/lang/en.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,15 @@ command-connect-description-mode = Connection mode: 'direct' (default) or 'gatew
command-connect-description-tenant = The Entra ID tenant ID to authenticate against.
command-connect-description-authority-host = The authority host URL (The default is https://login.microsoftonline.com/).
command-connect-description-managed-identity = The client ID of a user-assigned managed identity to authenticate with.
command-connect-description-emulator = Connect to the local Cosmos DB Emulator using its well-known account key. Defaults the endpoint to https://localhost:8081/ and falls back to HTTP if the TLS handshake fails.
command-connect-error-no_endpoint = An account endpoint or connection string must be specified.
command-connect-connected = Connected to account '{ $account }'
command-connect-emulator-detected = Emulator endpoint detected, using well-known account key and gateway mode.
command-connect-emulator-non_local = --emulator can only be used with a local endpoint (localhost or 127.0.0.1).
command-connect-emulator-https_failed = HTTPS handshake failed for emulator endpoint, retrying over HTTP...
command-connect-emulator-using_https = Connected to emulator over HTTPS at { $endpoint }.
command-connect-emulator-using_http = Connected to emulator over HTTP at { $endpoint }.
command-connect-emulator-http_tip = Tip: the Cosmos DB emulator Docker image accepts --protocol [https|http]; passing --protocol http skips TLS certificate setup.
command-connect-switching = Disconnecting from '{ $endpoint }'...
command-connect-not_connected = Not connected to any Cosmos DB account.
command-connect-info-title = Connection Information
Expand Down Expand Up @@ -474,6 +480,7 @@ help-ConnectHint = Login hint for browser authentication at startup.
help-ConnectAuthorityHost = The authority host URL at startup (default: https://login.microsoftonline.com/).
help-ConnectManagedIdentity = The client ID of a user-assigned managed identity at startup.
help-ConnectVSCodeCredential = Use Visual Studio Code credential for authentication at startup.
help-ConnectEmulator = Connect to the local Cosmos DB Emulator at startup using its well-known key.
help-EnableMcpServer = Enable MCP server for programmatic control of the shell
help-EnableLspServer = Enable Language Server Protocol (LSP) server for editor integration
help-McpPort = Enable MCP HTTP server. Optionally specify a port with --mcp <port>; default is 6128.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ Packaging runs produce preview versions in the form `1.0.<run>-preview.<branch>`
| `--connect-hint <email>` | Login hint for interactive login |
| `--connect-authority-host <uri>` | Authority host (e.g. sovereign clouds) |
| `--connect-managed-identity <id>` | Use a user-assigned managed identity |
| `--connect-emulator` | Connect to the local Cosmos DB Emulator (HTTPS, with HTTP fallback) |
| `--mcp [port]` | Enable MCP server on the given port, or `6128` by default |
| `--verbose` | Print full exception details |
| `--cs <n>` | Colors: 0=off, 1=standard, 2=truecolor |
Expand Down
11 changes: 10 additions & 1 deletion docs/connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,19 @@ connect https://myaccount.documents.azure.com:443/
### Emulator

```bash
# Plain URL — automatically uses well-known emulator key + gateway mode
# Shortcut: assumes https://localhost:8081/, well-known key, gateway mode,
# and falls back to HTTP if the TLS handshake fails.
connect --emulator

# Equivalent at startup
cosmosdbshell --connect-emulator

# Or pass an explicit emulator endpoint
connect https://localhost:8081
```

After a successful emulator connection the shell prints the protocol that was actually used (HTTPS or HTTP). When HTTP is used the shell points to the Docker emulator's `--protocol [https|http]` flag, which lets you skip TLS certificate setup.

### Managed Identity (User-Assigned)

```bash
Expand Down
Loading