mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Add --mode standalone|server CLI support for arc models list
Commands can now delegate to a running Arc API server instead of executing in-process. Adds ExecutionMode, ServerDefaults, and ClientTlsConfig to cli.toml parsing with CLI flag > config > default precedence. The models list command fetches from GET /models when in server mode, with mTLS client certificate auth when [server.tls] is configured. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2eae4224e1
commit
73d2d7918c
6 changed files with 496 additions and 5 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -277,6 +277,7 @@ dependencies = [
|
|||
"dotenvy",
|
||||
"futures",
|
||||
"http",
|
||||
"httpmock",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,30 @@
|
|||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use arc_agent::cli::{OutputFormat, PermissionLevel};
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExecutionMode {
|
||||
#[default]
|
||||
Standalone,
|
||||
Server,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
|
||||
pub struct ClientTlsConfig {
|
||||
pub cert: PathBuf,
|
||||
pub key: PathBuf,
|
||||
pub ca: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
|
||||
pub struct ServerDefaults {
|
||||
pub base_url: Option<String>,
|
||||
pub tls: Option<ClientTlsConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
|
||||
pub struct AgentDefaults {
|
||||
pub provider: Option<String>,
|
||||
|
|
@ -19,10 +40,77 @@ pub struct LlmDefaults {
|
|||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
|
||||
pub struct CliConfig {
|
||||
pub mode: Option<ExecutionMode>,
|
||||
pub server: Option<ServerDefaults>,
|
||||
pub agent: Option<AgentDefaults>,
|
||||
pub llm: Option<LlmDefaults>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ResolvedMode {
|
||||
pub mode: ExecutionMode,
|
||||
pub server_base_url: String,
|
||||
pub tls: Option<ClientTlsConfig>,
|
||||
}
|
||||
|
||||
const DEFAULT_SERVER_URL: &str = "http://localhost:3000";
|
||||
|
||||
pub fn resolve_mode(
|
||||
cli_mode: Option<ExecutionMode>,
|
||||
cli_server_url: Option<&str>,
|
||||
config: &CliConfig,
|
||||
) -> ResolvedMode {
|
||||
let mode = cli_mode
|
||||
.or_else(|| config.mode.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let server_defaults = config.server.as_ref();
|
||||
|
||||
let server_base_url = cli_server_url
|
||||
.map(String::from)
|
||||
.or_else(|| server_defaults.and_then(|s| s.base_url.clone()))
|
||||
.unwrap_or_else(|| DEFAULT_SERVER_URL.to_string());
|
||||
|
||||
let tls = server_defaults.and_then(|s| s.tls.clone());
|
||||
|
||||
debug!(mode = ?mode, base_url = %server_base_url, tls = tls.is_some(), "CLI mode resolved");
|
||||
|
||||
ResolvedMode {
|
||||
mode,
|
||||
server_base_url,
|
||||
tls,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_server_client(tls: Option<&ClientTlsConfig>) -> anyhow::Result<reqwest::Client> {
|
||||
let Some(tls) = tls else {
|
||||
return Ok(reqwest::Client::new());
|
||||
};
|
||||
|
||||
let cert_path = arc_api::tls::expand_tilde(&tls.cert);
|
||||
let key_path = arc_api::tls::expand_tilde(&tls.key);
|
||||
let ca_path = arc_api::tls::expand_tilde(&tls.ca);
|
||||
|
||||
let cert_pem = std::fs::read(&cert_path)?;
|
||||
let key_pem = std::fs::read(&key_path)?;
|
||||
let ca_pem = std::fs::read(&ca_path)?;
|
||||
|
||||
let mut identity_pem = cert_pem;
|
||||
identity_pem.push(b'\n');
|
||||
identity_pem.extend_from_slice(&key_pem);
|
||||
|
||||
let identity = reqwest::Identity::from_pem(&identity_pem)?;
|
||||
let ca_cert = reqwest::Certificate::from_pem(&ca_pem)?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.use_rustls_tls()
|
||||
.identity(identity)
|
||||
.add_root_certificate(ca_cert)
|
||||
.build()?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Load CLI config from an explicit path or `~/.arc/cli.toml`, returning defaults if the
|
||||
/// default file doesn't exist. An explicit path that doesn't exist is an error.
|
||||
pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result<CliConfig> {
|
||||
|
|
@ -118,4 +206,139 @@ model = "gemini-pro"
|
|||
let result = load_cli_config(Some(&path));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- ExecutionMode parsing ---
|
||||
|
||||
#[test]
|
||||
fn parse_mode_server() {
|
||||
let toml = r#"mode = "server""#;
|
||||
let config: CliConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.mode, Some(ExecutionMode::Server));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mode_standalone() {
|
||||
let toml = r#"mode = "standalone""#;
|
||||
let config: CliConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.mode, Some(ExecutionMode::Standalone));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mode_absent() {
|
||||
let config: CliConfig = toml::from_str("").unwrap();
|
||||
assert_eq!(config.mode, None);
|
||||
}
|
||||
|
||||
// --- ServerDefaults parsing ---
|
||||
|
||||
#[test]
|
||||
fn parse_server_base_url() {
|
||||
let toml = r#"
|
||||
[server]
|
||||
base_url = "https://arc.example.com:3000"
|
||||
"#;
|
||||
let config: CliConfig = toml::from_str(toml).unwrap();
|
||||
let server = config.server.unwrap();
|
||||
assert_eq!(
|
||||
server.base_url.as_deref(),
|
||||
Some("https://arc.example.com:3000")
|
||||
);
|
||||
assert_eq!(server.tls, None);
|
||||
}
|
||||
|
||||
// --- ClientTlsConfig parsing ---
|
||||
|
||||
#[test]
|
||||
fn parse_server_tls() {
|
||||
let toml = r#"
|
||||
[server]
|
||||
base_url = "https://arc.example.com:3000"
|
||||
|
||||
[server.tls]
|
||||
cert = "~/.arc/tls/client.crt"
|
||||
key = "~/.arc/tls/client.key"
|
||||
ca = "~/.arc/tls/ca.crt"
|
||||
"#;
|
||||
let config: CliConfig = toml::from_str(toml).unwrap();
|
||||
let tls = config.server.unwrap().tls.unwrap();
|
||||
assert_eq!(tls.cert, PathBuf::from("~/.arc/tls/client.crt"));
|
||||
assert_eq!(tls.key, PathBuf::from("~/.arc/tls/client.key"));
|
||||
assert_eq!(tls.ca, PathBuf::from("~/.arc/tls/ca.crt"));
|
||||
}
|
||||
|
||||
// --- resolve_mode precedence ---
|
||||
|
||||
#[test]
|
||||
fn resolve_mode_defaults_to_standalone() {
|
||||
let config = CliConfig::default();
|
||||
let resolved = resolve_mode(None, None, &config);
|
||||
assert_eq!(resolved.mode, ExecutionMode::Standalone);
|
||||
assert_eq!(resolved.server_base_url, DEFAULT_SERVER_URL);
|
||||
assert_eq!(resolved.tls, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_mode_config_overrides_default() {
|
||||
let config = CliConfig {
|
||||
mode: Some(ExecutionMode::Server),
|
||||
server: Some(ServerDefaults {
|
||||
base_url: Some("https://config.example.com".to_string()),
|
||||
tls: None,
|
||||
}),
|
||||
..CliConfig::default()
|
||||
};
|
||||
let resolved = resolve_mode(None, None, &config);
|
||||
assert_eq!(resolved.mode, ExecutionMode::Server);
|
||||
assert_eq!(resolved.server_base_url, "https://config.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_mode_cli_overrides_config() {
|
||||
let config = CliConfig {
|
||||
mode: Some(ExecutionMode::Standalone),
|
||||
server: Some(ServerDefaults {
|
||||
base_url: Some("https://config.example.com".to_string()),
|
||||
tls: None,
|
||||
}),
|
||||
..CliConfig::default()
|
||||
};
|
||||
let resolved = resolve_mode(
|
||||
Some(ExecutionMode::Server),
|
||||
Some("https://cli.example.com"),
|
||||
&config,
|
||||
);
|
||||
assert_eq!(resolved.mode, ExecutionMode::Server);
|
||||
assert_eq!(resolved.server_base_url, "https://cli.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_mode_cli_url_overrides_config_url() {
|
||||
let config = CliConfig {
|
||||
server: Some(ServerDefaults {
|
||||
base_url: Some("https://config.example.com".to_string()),
|
||||
tls: None,
|
||||
}),
|
||||
..CliConfig::default()
|
||||
};
|
||||
let resolved = resolve_mode(None, Some("https://cli.example.com"), &config);
|
||||
assert_eq!(resolved.server_base_url, "https://cli.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_mode_tls_from_config() {
|
||||
let tls = ClientTlsConfig {
|
||||
cert: PathBuf::from("cert.pem"),
|
||||
key: PathBuf::from("key.pem"),
|
||||
ca: PathBuf::from("ca.pem"),
|
||||
};
|
||||
let config = CliConfig {
|
||||
server: Some(ServerDefaults {
|
||||
base_url: None,
|
||||
tls: Some(tls.clone()),
|
||||
}),
|
||||
..CliConfig::default()
|
||||
};
|
||||
let resolved = resolve_mode(None, None, &config);
|
||||
assert_eq!(resolved.tls, Some(tls));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,26 @@ struct Cli {
|
|||
#[arg(long, global = true)]
|
||||
debug: bool,
|
||||
|
||||
/// Execution mode: standalone (in-process) or server (delegate to API)
|
||||
#[arg(long, global = true, value_parser = parse_execution_mode)]
|
||||
mode: Option<cli_config::ExecutionMode>,
|
||||
|
||||
/// Server URL (overrides server.base_url from cli.toml)
|
||||
#[arg(long, global = true)]
|
||||
server_url: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
fn parse_execution_mode(s: &str) -> Result<cli_config::ExecutionMode, String> {
|
||||
match s {
|
||||
"standalone" => Ok(cli_config::ExecutionMode::Standalone),
|
||||
"server" => Ok(cli_config::ExecutionMode::Server),
|
||||
_ => Err(format!("invalid mode '{s}', expected 'standalone' or 'server'")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// LLM prompt operations
|
||||
|
|
@ -184,7 +200,25 @@ async fn main() -> Result<()> {
|
|||
Command::Parse(args) => {
|
||||
arc_workflows::cli::parse::parse_command(&args)?;
|
||||
}
|
||||
Command::Models { command } => arc_llm::cli::run_models(command).await?,
|
||||
Command::Models { command } => {
|
||||
let cli_config = cli_config::load_cli_config(None)?;
|
||||
let resolved = cli_config::resolve_mode(
|
||||
cli.mode,
|
||||
cli.server_url.as_deref(),
|
||||
&cli_config,
|
||||
);
|
||||
let server = match resolved.mode {
|
||||
cli_config::ExecutionMode::Server => {
|
||||
let client = cli_config::build_server_client(resolved.tls.as_ref())?;
|
||||
Some(arc_llm::cli::ServerConnection {
|
||||
client,
|
||||
base_url: resolved.server_base_url,
|
||||
})
|
||||
}
|
||||
cli_config::ExecutionMode::Standalone => None,
|
||||
};
|
||||
arc_llm::cli::run_models(command, server).await?
|
||||
}
|
||||
Command::Serve(args) => {
|
||||
let styles: &'static arc_util::terminal::Styles =
|
||||
Box::leak(Box::new(arc_util::terminal::Styles::detect_stderr()));
|
||||
|
|
|
|||
|
|
@ -36,3 +36,5 @@ arc-util = { path = "../arc-util" }
|
|||
http = "1"
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
dotenvy.workspace = true
|
||||
httpmock = "0.8"
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -7,12 +7,18 @@ use std::time::Duration;
|
|||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
|
||||
use arc_util::terminal::Styles;
|
||||
|
||||
use crate::catalog;
|
||||
use crate::generate::{self, GenerateParams};
|
||||
use crate::types::Message;
|
||||
use crate::types::{Message, ModelInfo};
|
||||
|
||||
pub struct ServerConnection {
|
||||
pub client: reqwest::Client,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PromptArgs {
|
||||
|
|
@ -348,7 +354,50 @@ pub async fn run_prompt(args: PromptArgs) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_models(command: Option<ModelsCommand>) -> Result<()> {
|
||||
#[derive(Deserialize)]
|
||||
struct PaginatedModelsResponse {
|
||||
data: Vec<ModelInfo>,
|
||||
}
|
||||
|
||||
async fn fetch_models_from_server(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
provider: Option<&str>,
|
||||
) -> Result<Vec<ModelInfo>> {
|
||||
let url = format!("{base_url}/models?page[limit]=100");
|
||||
tracing::debug!(url = %url, "Fetching models from server");
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to connect to server at {base_url}"))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!("Server returned {status}: {body}");
|
||||
}
|
||||
|
||||
let parsed: PaginatedModelsResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse models response from server")?;
|
||||
|
||||
let mut models = parsed.data;
|
||||
tracing::debug!(model_count = models.len(), "Models received from server");
|
||||
|
||||
if let Some(p) = provider {
|
||||
models.retain(|m| m.provider == p);
|
||||
}
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
pub async fn run_models(
|
||||
command: Option<ModelsCommand>,
|
||||
server: Option<ServerConnection>,
|
||||
) -> Result<()> {
|
||||
let command = command.unwrap_or(ModelsCommand::List {
|
||||
provider: None,
|
||||
query: None,
|
||||
|
|
@ -358,7 +407,12 @@ pub async fn run_models(command: Option<ModelsCommand>) -> Result<()> {
|
|||
|
||||
match command {
|
||||
ModelsCommand::List { provider, query } => {
|
||||
let mut models = catalog::list_models(provider.as_deref());
|
||||
let mut models = match &server {
|
||||
Some(s) => {
|
||||
fetch_models_from_server(&s.client, &s.base_url, provider.as_deref()).await?
|
||||
}
|
||||
None => catalog::list_models(provider.as_deref()),
|
||||
};
|
||||
|
||||
if let Some(q) = &query {
|
||||
let q_lower = q.to_lowercase();
|
||||
|
|
@ -374,6 +428,9 @@ pub async fn run_models(command: Option<ModelsCommand>) -> Result<()> {
|
|||
print_models_table(&models, &styles);
|
||||
}
|
||||
ModelsCommand::Test { provider, model } => {
|
||||
if server.is_some() {
|
||||
bail!("models test is not supported in server mode");
|
||||
}
|
||||
test_models(provider.as_deref(), model.as_deref(), &styles).await?;
|
||||
}
|
||||
}
|
||||
|
|
@ -633,4 +690,120 @@ mod tests {
|
|||
assert_eq!(result.max_tokens, None);
|
||||
assert_eq!(result.provider_options, None);
|
||||
}
|
||||
|
||||
// --- run_models server mode rejection ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_test_rejects_server_mode() {
|
||||
let server = ServerConnection {
|
||||
client: reqwest::Client::new(),
|
||||
base_url: "http://unused".to_string(),
|
||||
};
|
||||
let command = Some(ModelsCommand::Test {
|
||||
provider: None,
|
||||
model: None,
|
||||
});
|
||||
let result = run_models(command, Some(server)).await;
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"models test is not supported in server mode"
|
||||
);
|
||||
}
|
||||
|
||||
// --- fetch_models_from_server ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_models_from_server_parses_response() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let mock = server.mock_async(|when, then| {
|
||||
when.method("GET").path("/models").query_param("page[limit]", "100");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::json!({
|
||||
"data": [{
|
||||
"id": "test-model",
|
||||
"provider": "test-provider",
|
||||
"family": "test",
|
||||
"display_name": "Test Model",
|
||||
"limits": { "context_window": 128000, "max_output": 4096 },
|
||||
"training": null,
|
||||
"features": { "tools": true, "vision": false, "reasoning": false },
|
||||
"costs": { "input_cost_per_mtok": 1.0, "output_cost_per_mtok": 2.0, "cache_input_cost_per_mtok": null },
|
||||
"estimated_output_tps": 100.0,
|
||||
"aliases": ["tm"],
|
||||
"default": false
|
||||
}],
|
||||
"meta": { "has_more": false }
|
||||
}).to_string());
|
||||
}).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let models = fetch_models_from_server(&client, &server.url(""), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
mock.assert_async().await;
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].id, "test-model");
|
||||
assert_eq!(models[0].provider, "test-provider");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_models_from_server_filters_by_provider() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
server.mock_async(|when, then| {
|
||||
when.method("GET").path("/models");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::json!({
|
||||
"data": [
|
||||
{
|
||||
"id": "model-a",
|
||||
"provider": "alpha",
|
||||
"family": "a",
|
||||
"display_name": "Model A",
|
||||
"limits": { "context_window": 8000 },
|
||||
"features": { "tools": false, "vision": false, "reasoning": false },
|
||||
"costs": {},
|
||||
"aliases": [],
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"id": "model-b",
|
||||
"provider": "beta",
|
||||
"family": "b",
|
||||
"display_name": "Model B",
|
||||
"limits": { "context_window": 8000 },
|
||||
"features": { "tools": false, "vision": false, "reasoning": false },
|
||||
"costs": {},
|
||||
"aliases": [],
|
||||
"default": false
|
||||
}
|
||||
],
|
||||
"meta": { "has_more": false }
|
||||
}).to_string());
|
||||
}).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let models = fetch_models_from_server(&client, &server.url(""), Some("alpha"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].id, "model-a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_models_from_server_error_on_failure() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
server.mock_async(|when, then| {
|
||||
when.method("GET").path("/models");
|
||||
then.status(500).body("internal error");
|
||||
}).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let result = fetch_models_from_server(&client, &server.url(""), None).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ CLI flags always take the highest priority:
|
|||
## Full example
|
||||
|
||||
```toml
|
||||
mode = "server"
|
||||
|
||||
[server]
|
||||
base_url = "https://arc.example.com:3000"
|
||||
|
||||
[server.tls]
|
||||
cert = "~/.arc/tls/client.crt"
|
||||
key = "~/.arc/tls/client.key"
|
||||
ca = "~/.arc/tls/ca.crt"
|
||||
|
||||
[agent]
|
||||
provider = "anthropic"
|
||||
model = "claude-opus-4-6"
|
||||
|
|
@ -67,3 +77,51 @@ Defaults for `arc llm prompt` and `arc llm chat`.
|
|||
<Note>
|
||||
The `[llm]` section only sets the default model. Use `[agent]` to configure provider, permissions, and output format for `arc agent`.
|
||||
</Note>
|
||||
|
||||
## `mode`
|
||||
|
||||
Controls whether commands run in-process or delegate to a running Arc API server.
|
||||
|
||||
| Value | Description |
|
||||
|---|---|
|
||||
| `"standalone"` | Execute locally (default) |
|
||||
| `"server"` | Delegate to an Arc API server |
|
||||
|
||||
Override with the `--mode` CLI flag:
|
||||
|
||||
```bash
|
||||
arc --mode server models list
|
||||
```
|
||||
|
||||
## `[server]` section
|
||||
|
||||
Configuration for server mode.
|
||||
|
||||
| Key | Description | Default |
|
||||
|---|---|---|
|
||||
| `base_url` | Server URL | `"http://localhost:3000"` |
|
||||
|
||||
Override the URL with the `--server-url` CLI flag:
|
||||
|
||||
```bash
|
||||
arc --mode server --server-url https://arc.example.com:3000 models list
|
||||
```
|
||||
|
||||
### `[server.tls]` section
|
||||
|
||||
Optional mTLS configuration for authenticating with the server. When present, the CLI presents a client certificate during the TLS handshake.
|
||||
|
||||
| Key | Description |
|
||||
|---|---|
|
||||
| `cert` | Path to client certificate PEM file |
|
||||
| `key` | Path to client private key PEM file |
|
||||
| `ca` | Path to CA certificate PEM file (to verify the server) |
|
||||
|
||||
Paths support `~/` expansion. Example:
|
||||
|
||||
```toml
|
||||
[server.tls]
|
||||
cert = "~/.arc/tls/client.crt"
|
||||
key = "~/.arc/tls/client.key"
|
||||
ca = "~/.arc/tls/ca.crt"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue