detached ab7fb37

Scope --insecure TLS to explicit base URL, drop global transport override

Builds on s3rj1k's --insecure flag (#35) but limits insecure TLS to the
resolved inference client for an explicit --base-url, instead of mutating
http.DefaultTransport process-wide. Built-in providers, auth, and model
discovery keep normal certificate verification. Documents the flag in
the CLI reference.

Co-authored-by: s3rj1k <evasive.gyron@gmail.com>
commit
ab7fb37046c36ec7f804520e1cbbda674a1f9969
author
patriceckhart · · 2026-06-16 05:41 UTC
parent
e0ca3fd
signature
unsigned
5 files · +149 -89
M README.md +1 -0
@@ -141,6 +141,7 @@ zot --help
141141 | `--model <id>` | Pick the model (see `--list-models`). |
142142 | `--api-key <key>` | Override the API key. |
143143 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
144+| `--insecure` | Skip TLS certificate verification, only for the explicit `--base-url` endpoint (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |
144145 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
145146 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
146147 | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |
@@ -141,6 +141,7 @@ zot --help
141 | `--model <id>` | Pick the model (see `--list-models`). |141 | `--model <id>` | Pick the model (see `--list-models`). |
142 | `--api-key <key>` | Override the API key. |142 | `--api-key <key>` | Override the API key. |
143 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |143 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
144+| `--insecure` | Skip TLS certificate verification, only for the explicit `--base-url` endpoint (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |
144 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |145 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
145 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |146 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
146 | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |147 | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |
M packages/agent/build.go +54 -42
@@ -23,6 +23,7 @@ type Resolved struct {
2323 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
2424 AccountID string // ChatGPT account id (for openai oauth), "" otherwise
2525 BaseURL string
26+ InsecureTLS bool
2627 CWD string
2728 Reasoning string
2829 Temperature *float32
@@ -409,6 +410,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
409410 model = fm.ID
410411 }
411412
413+ explicitBaseURL := args.BaseURL != ""
414+
412415 // If the model defines a base URL (e.g. local ollama) and the
413416 // user didn't pass --base-url, use the model's URL. For ollama,
414417 // keep http://localhost:11434 as a fallback only after the model
@@ -420,10 +423,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
420423 args.BaseURL = "http://localhost:11434"
421424 }
422425
423- provider.InsecureSkipVerify = (args.InsecureTLS || cfg.Insecure) && args.BaseURL != ""
424- if provider.InsecureSkipVerify {
425- provider.ApplyInsecureTLS()
426- }
426+ // Insecure TLS is intentionally scoped to explicit custom endpoints.
427+ // Built-in provider base URLs, auth calls, and model discovery keep normal
428+ // certificate verification even when --insecure is present.
429+ insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
427430
428431 // If the model has a base URL, credentials are optional (local
429432 // models like ollama don't need real API keys).
@@ -512,6 +515,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
512515 AuthMethod: method,
513516 AccountID: accountID,
514517 BaseURL: args.BaseURL,
518+ InsecureTLS: insecureTLS,
515519 CWD: args.CWD,
516520 Reasoning: reasoning,
517521 Temperature: temperature,
@@ -639,90 +643,98 @@ func (r Resolved) NewClient() provider.Client {
639643 if !r.HasCredential() {
640644 panic("NewClient called without credential; check HasCredential first")
641645 }
646+ wrap := r.withHTTPClient
642647 switch r.Provider {
643648 case "ollama":
644- return provider.NewOpenAI(r.Credential, r.BaseURL)
649+ return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
645650 case "kimi":
646651 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
647652 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
648- inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
653+ inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
649654 if r.AuthMethod == "oauth" {
650655 return r.wrapWithRefresh(inner)
651656 }
652657 return inner
653658 case "moonshotai":
654- return provider.NewMoonshot(r.Credential, r.BaseURL)
659+ return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
655660 case "moonshotai-cn":
656- return provider.NewMoonshotCN(r.Credential, r.BaseURL)
661+ return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
657662 case "deepseek":
658- return provider.NewDeepSeek(r.Credential, r.BaseURL)
663+ return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
659664 case "openai":
660- return provider.NewOpenAI(r.Credential, r.BaseURL)
665+ return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
661666 case "openai-codex":
662- inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
667+ inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
663668 return r.wrapWithRefresh(inner)
664669 case "openai-responses":
665670 // Public OpenAI Responses API (api.openai.com/v1/responses) via
666671 // API key. Separate provider from `openai` (Chat Completions) and
667672 // from `openai-codex` (ChatGPT subscription OAuth).
668- return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
673+ return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
669674 case "google":
670- return provider.NewGemini(r.Credential, r.BaseURL)
675+ return wrap(provider.NewGemini(r.Credential, r.BaseURL))
671676 case "cerebras":
672- return provider.NewCerebras(r.Credential, r.BaseURL)
677+ return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
673678 case "groq":
674- return provider.NewGroq(r.Credential, r.BaseURL)
679+ return wrap(provider.NewGroq(r.Credential, r.BaseURL))
675680 case "xai":
676- return provider.NewXAI(r.Credential, r.BaseURL)
681+ return wrap(provider.NewXAI(r.Credential, r.BaseURL))
677682 case "together":
678- return provider.NewTogether(r.Credential, r.BaseURL)
683+ return wrap(provider.NewTogether(r.Credential, r.BaseURL))
679684 case "huggingface":
680- return provider.NewHuggingFace(r.Credential, r.BaseURL)
685+ return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
681686 case "openrouter":
682- return provider.NewOpenRouter(r.Credential, r.BaseURL)
687+ return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
683688 case "zai":
684- return provider.NewZAI(r.Credential, r.BaseURL)
689+ return wrap(provider.NewZAI(r.Credential, r.BaseURL))
685690 case "xiaomi":
686- return provider.NewXiaomi(r.Credential, r.BaseURL)
691+ return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
687692 case "xiaomi-token-plan-ams":
688- return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
693+ return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
689694 case "xiaomi-token-plan-cn":
690- return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
695+ return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
691696 case "xiaomi-token-plan-sgp":
692- return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
697+ return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
693698 case "opencode":
694- return provider.NewOpenCode(r.Credential, r.BaseURL)
699+ return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
695700 case "opencode-go":
696- return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
701+ return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
697702 case "minimax":
698- return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
703+ return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
699704 case "minimax-cn":
700- return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
705+ return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
701706 case "fireworks":
702- return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
707+ return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
703708 case "vercel-ai-gateway":
704- return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
709+ return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
705710 case "mistral":
706- return provider.NewMistral(r.Credential, r.BaseURL)
711+ return wrap(provider.NewMistral(r.Credential, r.BaseURL))
707712 case "amazon-bedrock":
708- return provider.NewBedrock(r.Credential, r.BaseURL)
713+ return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
709714 case "google-vertex":
710- return provider.NewGoogleVertex(r.Credential, r.BaseURL)
715+ return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
711716 case "azure-openai-responses":
712- return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
717+ return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
713718 case "github-copilot":
714- return provider.NewGithubCopilot(r.Credential, r.BaseURL)
719+ return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
715720 case "cloudflare-workers-ai":
716- return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
721+ return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
717722 case "cloudflare-ai-gateway":
718- return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
723+ return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
719724 default:
720725 if r.AuthMethod == "oauth" {
721- inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
726+ inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
722727 return r.wrapWithRefresh(inner)
723728 }
724- return provider.NewAnthropic(r.Credential, r.BaseURL)
729+ return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
730+ }
731+}
732+
733+func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
734+ if !r.InsecureTLS {
735+ return c
725736 }
737+ return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
726738 }
727739
728740 // wrapWithRefresh wraps an OAuth client so the access token is
@@ -748,12 +760,12 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
748760 factory := func(token string) provider.Client {
749761 switch provName {
750762 case "openai-codex":
751- return provider.NewOpenAICodex(token, accountID, baseURL)
763+ return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
752764 case "kimi":
753765 // anthropic-messages on api.kimi.com/coding.
754- return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
766+ return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
755767 default:
756- return provider.NewAnthropicOAuth(token, baseURL)
768+ return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
757769 }
758770 }
759771
@@ -23,6 +23,7 @@ type Resolved struct {
23 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)23 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
24 AccountID string // ChatGPT account id (for openai oauth), "" otherwise24 AccountID string // ChatGPT account id (for openai oauth), "" otherwise
25 BaseURL string25 BaseURL string
26+ InsecureTLS bool
26 CWD string27 CWD string
27 Reasoning string28 Reasoning string
28 Temperature *float3229 Temperature *float32
@@ -409,6 +410,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
409 model = fm.ID410 model = fm.ID
410 }411 }
411 412
413+ explicitBaseURL := args.BaseURL != ""
414+
412 // If the model defines a base URL (e.g. local ollama) and the415 // If the model defines a base URL (e.g. local ollama) and the
413 // user didn't pass --base-url, use the model's URL. For ollama,416 // user didn't pass --base-url, use the model's URL. For ollama,
414 // keep http://localhost:11434 as a fallback only after the model417 // keep http://localhost:11434 as a fallback only after the model
@@ -420,10 +423,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
420 args.BaseURL = "http://localhost:11434"423 args.BaseURL = "http://localhost:11434"
421 }424 }
422 425
423- provider.InsecureSkipVerify = (args.InsecureTLS || cfg.Insecure) && args.BaseURL != ""426+ // Insecure TLS is intentionally scoped to explicit custom endpoints.
424- if provider.InsecureSkipVerify {427+ // Built-in provider base URLs, auth calls, and model discovery keep normal
425- provider.ApplyInsecureTLS()428+ // certificate verification even when --insecure is present.
426- }429+ insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
427 430
428 // If the model has a base URL, credentials are optional (local431 // If the model has a base URL, credentials are optional (local
429 // models like ollama don't need real API keys).432 // models like ollama don't need real API keys).
@@ -512,6 +515,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
512 AuthMethod: method,515 AuthMethod: method,
513 AccountID: accountID,516 AccountID: accountID,
514 BaseURL: args.BaseURL,517 BaseURL: args.BaseURL,
518+ InsecureTLS: insecureTLS,
515 CWD: args.CWD,519 CWD: args.CWD,
516 Reasoning: reasoning,520 Reasoning: reasoning,
517 Temperature: temperature,521 Temperature: temperature,
@@ -639,90 +643,98 @@ func (r Resolved) NewClient() provider.Client {
639 if !r.HasCredential() {643 if !r.HasCredential() {
640 panic("NewClient called without credential; check HasCredential first")644 panic("NewClient called without credential; check HasCredential first")
641 }645 }
646+ wrap := r.withHTTPClient
642 switch r.Provider {647 switch r.Provider {
643 case "ollama":648 case "ollama":
644- return provider.NewOpenAI(r.Credential, r.BaseURL)649+ return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
645 case "kimi":650 case "kimi":
646 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.651 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
647 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.652 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
648- inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())653+ inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
649 if r.AuthMethod == "oauth" {654 if r.AuthMethod == "oauth" {
650 return r.wrapWithRefresh(inner)655 return r.wrapWithRefresh(inner)
651 }656 }
652 return inner657 return inner
653 case "moonshotai":658 case "moonshotai":
654- return provider.NewMoonshot(r.Credential, r.BaseURL)659+ return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
655 case "moonshotai-cn":660 case "moonshotai-cn":
656- return provider.NewMoonshotCN(r.Credential, r.BaseURL)661+ return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
657 case "deepseek":662 case "deepseek":
658- return provider.NewDeepSeek(r.Credential, r.BaseURL)663+ return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
659 case "openai":664 case "openai":
660- return provider.NewOpenAI(r.Credential, r.BaseURL)665+ return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
661 case "openai-codex":666 case "openai-codex":
662- inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)667+ inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
663 return r.wrapWithRefresh(inner)668 return r.wrapWithRefresh(inner)
664 case "openai-responses":669 case "openai-responses":
665 // Public OpenAI Responses API (api.openai.com/v1/responses) via670 // Public OpenAI Responses API (api.openai.com/v1/responses) via
666 // API key. Separate provider from `openai` (Chat Completions) and671 // API key. Separate provider from `openai` (Chat Completions) and
667 // from `openai-codex` (ChatGPT subscription OAuth).672 // from `openai-codex` (ChatGPT subscription OAuth).
668- return provider.NewOpenAIResponses(r.Credential, r.BaseURL)673+ return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
669 case "google":674 case "google":
670- return provider.NewGemini(r.Credential, r.BaseURL)675+ return wrap(provider.NewGemini(r.Credential, r.BaseURL))
671 case "cerebras":676 case "cerebras":
672- return provider.NewCerebras(r.Credential, r.BaseURL)677+ return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
673 case "groq":678 case "groq":
674- return provider.NewGroq(r.Credential, r.BaseURL)679+ return wrap(provider.NewGroq(r.Credential, r.BaseURL))
675 case "xai":680 case "xai":
676- return provider.NewXAI(r.Credential, r.BaseURL)681+ return wrap(provider.NewXAI(r.Credential, r.BaseURL))
677 case "together":682 case "together":
678- return provider.NewTogether(r.Credential, r.BaseURL)683+ return wrap(provider.NewTogether(r.Credential, r.BaseURL))
679 case "huggingface":684 case "huggingface":
680- return provider.NewHuggingFace(r.Credential, r.BaseURL)685+ return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
681 case "openrouter":686 case "openrouter":
682- return provider.NewOpenRouter(r.Credential, r.BaseURL)687+ return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
683 case "zai":688 case "zai":
684- return provider.NewZAI(r.Credential, r.BaseURL)689+ return wrap(provider.NewZAI(r.Credential, r.BaseURL))
685 case "xiaomi":690 case "xiaomi":
686- return provider.NewXiaomi(r.Credential, r.BaseURL)691+ return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
687 case "xiaomi-token-plan-ams":692 case "xiaomi-token-plan-ams":
688- return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)693+ return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
689 case "xiaomi-token-plan-cn":694 case "xiaomi-token-plan-cn":
690- return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)695+ return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
691 case "xiaomi-token-plan-sgp":696 case "xiaomi-token-plan-sgp":
692- return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)697+ return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
693 case "opencode":698 case "opencode":
694- return provider.NewOpenCode(r.Credential, r.BaseURL)699+ return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
695 case "opencode-go":700 case "opencode-go":
696- return provider.NewOpenCodeGo(r.Credential, r.BaseURL)701+ return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
697 case "minimax":702 case "minimax":
698- return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)703+ return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
699 case "minimax-cn":704 case "minimax-cn":
700- return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)705+ return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
701 case "fireworks":706 case "fireworks":
702- return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)707+ return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
703 case "vercel-ai-gateway":708 case "vercel-ai-gateway":
704- return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)709+ return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
705 case "mistral":710 case "mistral":
706- return provider.NewMistral(r.Credential, r.BaseURL)711+ return wrap(provider.NewMistral(r.Credential, r.BaseURL))
707 case "amazon-bedrock":712 case "amazon-bedrock":
708- return provider.NewBedrock(r.Credential, r.BaseURL)713+ return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
709 case "google-vertex":714 case "google-vertex":
710- return provider.NewGoogleVertex(r.Credential, r.BaseURL)715+ return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
711 case "azure-openai-responses":716 case "azure-openai-responses":
712- return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)717+ return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
713 case "github-copilot":718 case "github-copilot":
714- return provider.NewGithubCopilot(r.Credential, r.BaseURL)719+ return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
715 case "cloudflare-workers-ai":720 case "cloudflare-workers-ai":
716- return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)721+ return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
717 case "cloudflare-ai-gateway":722 case "cloudflare-ai-gateway":
718- return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)723+ return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
719 default:724 default:
720 if r.AuthMethod == "oauth" {725 if r.AuthMethod == "oauth" {
721- inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)726+ inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
722 return r.wrapWithRefresh(inner)727 return r.wrapWithRefresh(inner)
723 }728 }
724- return provider.NewAnthropic(r.Credential, r.BaseURL)729+ return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
730+ }
731+}
732+
733+func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
734+ if !r.InsecureTLS {
735+ return c
725 }736 }
737+ return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
726 }738 }
727 739
728 // wrapWithRefresh wraps an OAuth client so the access token is740 // wrapWithRefresh wraps an OAuth client so the access token is
@@ -748,12 +760,12 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
748 factory := func(token string) provider.Client {760 factory := func(token string) provider.Client {
749 switch provName {761 switch provName {
750 case "openai-codex":762 case "openai-codex":
751- return provider.NewOpenAICodex(token, accountID, baseURL)763+ return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
752 case "kimi":764 case "kimi":
753 // anthropic-messages on api.kimi.com/coding.765 // anthropic-messages on api.kimi.com/coding.
754- return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())766+ return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
755 default:767 default:
756- return provider.NewAnthropicOAuth(token, baseURL)768+ return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
757 }769 }
758 }770 }
759 771
M packages/agent/build_test.go +34 -26
@@ -1,6 +1,7 @@
11 package agent
22
33 import (
4+ "net/http"
45 "os"
56 "path/filepath"
67 "strings"
@@ -212,37 +213,35 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
212213 }
213214 }
214215
215-func TestResolveInsecureOnlyWithCustomBaseURL(t *testing.T) {
216- orig := provider.InsecureSkipVerify
217- t.Cleanup(func() { provider.InsecureSkipVerify = orig })
216+func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
217+ orig := http.DefaultTransport
218+ t.Cleanup(func() { http.DefaultTransport = orig })
218219
219220 t.Setenv("ZOT_HOME", t.TempDir())
220221 t.Setenv("OPENAI_API_KEY", "test-key")
221222
222- // no --base-url: must stay false even with --insecure.
223- provider.InsecureSkipVerify = false
224- _, err := Resolve(Args{Provider: "openai", InsecureTLS: true}, false)
223+ resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
225224 if err != nil {
226225 t.Fatalf("Resolve: %v", err)
227226 }
228- if provider.InsecureSkipVerify {
229- t.Fatal("InsecureSkipVerify must not be set without a custom base URL")
227+ if resolved.InsecureTLS {
228+ t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
230229 }
230+ assertDefaultTransportStillSecure(t)
231231
232- // --base-url + --insecure: must be true.
233- provider.InsecureSkipVerify = false
234- _, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
232+ resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
235233 if err != nil {
236234 t.Fatalf("Resolve: %v", err)
237235 }
238- if !provider.InsecureSkipVerify {
239- t.Fatal("InsecureSkipVerify must be set with --insecure and --base-url")
236+ if !resolved.InsecureTLS {
237+ t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
240238 }
239+ assertDefaultTransportStillSecure(t)
241240 }
242241
243-func TestResolveInsecureFromConfig(t *testing.T) {
244- orig := provider.InsecureSkipVerify
245- t.Cleanup(func() { provider.InsecureSkipVerify = orig })
242+func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
243+ orig := http.DefaultTransport
244+ t.Cleanup(func() { http.DefaultTransport = orig })
246245
247246 t.Setenv("ZOT_HOME", t.TempDir())
248247 t.Setenv("OPENAI_API_KEY", "test-key")
@@ -250,23 +249,32 @@ func TestResolveInsecureFromConfig(t *testing.T) {
250249 t.Fatal(err)
251250 }
252251
253- // no --base-url: must stay false.
254- provider.InsecureSkipVerify = false
255- _, err := Resolve(Args{Provider: "openai"}, false)
252+ resolved, err := Resolve(Args{Provider: "openai"}, false)
256253 if err != nil {
257254 t.Fatalf("Resolve: %v", err)
258255 }
259- if provider.InsecureSkipVerify {
260- t.Fatal("InsecureSkipVerify must not be set without a custom base URL")
256+ if resolved.InsecureTLS {
257+ t.Fatal("InsecureTLS must not be set without a custom base URL")
261258 }
259+ assertDefaultTransportStillSecure(t)
262260
263- // --base-url: must be true.
264- provider.InsecureSkipVerify = false
265- _, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
261+ resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
266262 if err != nil {
267263 t.Fatalf("Resolve: %v", err)
268264 }
269- if !provider.InsecureSkipVerify {
270- t.Fatal("InsecureSkipVerify must be set when config insecure=true and --base-url is provided")
265+ if !resolved.InsecureTLS {
266+ t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
267+ }
268+ assertDefaultTransportStillSecure(t)
269+}
270+
271+func assertDefaultTransportStillSecure(t *testing.T) {
272+ t.Helper()
273+ tr, ok := http.DefaultTransport.(*http.Transport)
274+ if !ok {
275+ return
276+ }
277+ if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
278+ t.Fatal("http.DefaultTransport must not be made insecure")
271279 }
272280 }
@@ -1,6 +1,7 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "net/http"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "strings"7 "strings"
@@ -212,37 +213,35 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
212 }213 }
213 }214 }
214 215
215-func TestResolveInsecureOnlyWithCustomBaseURL(t *testing.T) {216+func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
216- orig := provider.InsecureSkipVerify217+ orig := http.DefaultTransport
217- t.Cleanup(func() { provider.InsecureSkipVerify = orig })218+ t.Cleanup(func() { http.DefaultTransport = orig })
218 219
219 t.Setenv("ZOT_HOME", t.TempDir())220 t.Setenv("ZOT_HOME", t.TempDir())
220 t.Setenv("OPENAI_API_KEY", "test-key")221 t.Setenv("OPENAI_API_KEY", "test-key")
221 222
222- // no --base-url: must stay false even with --insecure.223+ resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
223- provider.InsecureSkipVerify = false
224- _, err := Resolve(Args{Provider: "openai", InsecureTLS: true}, false)
225 if err != nil {224 if err != nil {
226 t.Fatalf("Resolve: %v", err)225 t.Fatalf("Resolve: %v", err)
227 }226 }
228- if provider.InsecureSkipVerify {227+ if resolved.InsecureTLS {
229- t.Fatal("InsecureSkipVerify must not be set without a custom base URL")228+ t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
230 }229 }
230+ assertDefaultTransportStillSecure(t)
231 231
232- // --base-url + --insecure: must be true.232+ resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
233- provider.InsecureSkipVerify = false
234- _, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
235 if err != nil {233 if err != nil {
236 t.Fatalf("Resolve: %v", err)234 t.Fatalf("Resolve: %v", err)
237 }235 }
238- if !provider.InsecureSkipVerify {236+ if !resolved.InsecureTLS {
239- t.Fatal("InsecureSkipVerify must be set with --insecure and --base-url")237+ t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
240 }238 }
239+ assertDefaultTransportStillSecure(t)
241 }240 }
242 241
243-func TestResolveInsecureFromConfig(t *testing.T) {242+func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
244- orig := provider.InsecureSkipVerify243+ orig := http.DefaultTransport
245- t.Cleanup(func() { provider.InsecureSkipVerify = orig })244+ t.Cleanup(func() { http.DefaultTransport = orig })
246 245
247 t.Setenv("ZOT_HOME", t.TempDir())246 t.Setenv("ZOT_HOME", t.TempDir())
248 t.Setenv("OPENAI_API_KEY", "test-key")247 t.Setenv("OPENAI_API_KEY", "test-key")
@@ -250,23 +249,32 @@ func TestResolveInsecureFromConfig(t *testing.T) {
250 t.Fatal(err)249 t.Fatal(err)
251 }250 }
252 251
253- // no --base-url: must stay false.252+ resolved, err := Resolve(Args{Provider: "openai"}, false)
254- provider.InsecureSkipVerify = false
255- _, err := Resolve(Args{Provider: "openai"}, false)
256 if err != nil {253 if err != nil {
257 t.Fatalf("Resolve: %v", err)254 t.Fatalf("Resolve: %v", err)
258 }255 }
259- if provider.InsecureSkipVerify {256+ if resolved.InsecureTLS {
260- t.Fatal("InsecureSkipVerify must not be set without a custom base URL")257+ t.Fatal("InsecureTLS must not be set without a custom base URL")
261 }258 }
259+ assertDefaultTransportStillSecure(t)
262 260
263- // --base-url: must be true.261+ resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
264- provider.InsecureSkipVerify = false
265- _, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
266 if err != nil {262 if err != nil {
267 t.Fatalf("Resolve: %v", err)263 t.Fatalf("Resolve: %v", err)
268 }264 }
269- if !provider.InsecureSkipVerify {265+ if !resolved.InsecureTLS {
270- t.Fatal("InsecureSkipVerify must be set when config insecure=true and --base-url is provided")266+ t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
267+ }
268+ assertDefaultTransportStillSecure(t)
269+}
270+
271+func assertDefaultTransportStillSecure(t *testing.T) {
272+ t.Helper()
273+ tr, ok := http.DefaultTransport.(*http.Transport)
274+ if !ok {
275+ return
276+ }
277+ if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
278+ t.Fatal("http.DefaultTransport must not be made insecure")
271 }279 }
272 }280 }
M packages/provider/httpclient.go +40 -6
@@ -5,12 +5,46 @@ import (
55 "net/http"
66 )
77
8-// InsecureSkipVerify disables TLS cert verification for inference connections.
9-var InsecureSkipVerify bool
8+// NewHTTPClient returns a provider HTTP client. When insecureTLS is true,
9+// only this client skips TLS certificate verification. The process-wide
10+// default transport is left untouched so auth, discovery, and other providers
11+// keep normal certificate validation.
12+func NewHTTPClient(insecureTLS bool) *http.Client {
13+ if !insecureTLS {
14+ return &http.Client{Timeout: 0}
15+ }
16+ tr, ok := http.DefaultTransport.(*http.Transport)
17+ if ok {
18+ tr = tr.Clone()
19+ } else {
20+ tr = &http.Transport{}
21+ }
22+ if tr.TLSClientConfig != nil {
23+ tr.TLSClientConfig = tr.TLSClientConfig.Clone()
24+ } else {
25+ tr.TLSClientConfig = &tls.Config{}
26+ }
27+ tr.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec
28+ return &http.Client{Timeout: 0, Transport: tr}
29+}
1030
11-// ApplyInsecureTLS replaces http.DefaultTransport to skip TLS cert verification.
12-func ApplyInsecureTLS() {
13- http.DefaultTransport = &http.Transport{
14- TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
31+// WithHTTPClient scopes an HTTP client to a concrete provider client.
32+// Unsupported clients are returned unchanged.
33+func WithHTTPClient(c Client, httpClient *http.Client) Client {
34+ if httpClient == nil {
35+ return c
36+ }
37+ switch v := c.(type) {
38+ case *openaiClient:
39+ v.http = httpClient
40+ case *anthropicClient:
41+ v.http = httpClient
42+ case *geminiClient:
43+ v.http = httpClient
44+ case *bedrockClient:
45+ v.http = httpClient
46+ case *renamedClient:
47+ v.inner = WithHTTPClient(v.inner, httpClient)
1548 }
49+ return c
1650 }
@@ -5,12 +5,46 @@ import (
5 "net/http"5 "net/http"
6 )6 )
7 7
8-// InsecureSkipVerify disables TLS cert verification for inference connections.8+// NewHTTPClient returns a provider HTTP client. When insecureTLS is true,
9-var InsecureSkipVerify bool9+// only this client skips TLS certificate verification. The process-wide
10+// default transport is left untouched so auth, discovery, and other providers
11+// keep normal certificate validation.
12+func NewHTTPClient(insecureTLS bool) *http.Client {
13+ if !insecureTLS {
14+ return &http.Client{Timeout: 0}
15+ }
16+ tr, ok := http.DefaultTransport.(*http.Transport)
17+ if ok {
18+ tr = tr.Clone()
19+ } else {
20+ tr = &http.Transport{}
21+ }
22+ if tr.TLSClientConfig != nil {
23+ tr.TLSClientConfig = tr.TLSClientConfig.Clone()
24+ } else {
25+ tr.TLSClientConfig = &tls.Config{}
26+ }
27+ tr.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec
28+ return &http.Client{Timeout: 0, Transport: tr}
29+}
10 30
11-// ApplyInsecureTLS replaces http.DefaultTransport to skip TLS cert verification.31+// WithHTTPClient scopes an HTTP client to a concrete provider client.
12-func ApplyInsecureTLS() {32+// Unsupported clients are returned unchanged.
13- http.DefaultTransport = &http.Transport{33+func WithHTTPClient(c Client, httpClient *http.Client) Client {
14- TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec34+ if httpClient == nil {
35+ return c
36+ }
37+ switch v := c.(type) {
38+ case *openaiClient:
39+ v.http = httpClient
40+ case *anthropicClient:
41+ v.http = httpClient
42+ case *geminiClient:
43+ v.http = httpClient
44+ case *bedrockClient:
45+ v.http = httpClient
46+ case *renamedClient:
47+ v.inner = WithHTTPClient(v.inner, httpClient)
15 }48 }
49+ return c
16 }50 }
M packages/provider/httpclient_test.go +20 -15
@@ -6,22 +6,24 @@ import (
66 "testing"
77 )
88
9-func TestApplyInsecureTLSSetsDefaultTransport(t *testing.T) {
9+func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) {
1010 orig := http.DefaultTransport
1111 t.Cleanup(func() { http.DefaultTransport = orig })
1212
13- ApplyInsecureTLS()
14-
15- tr, ok := http.DefaultTransport.(*http.Transport)
13+ client := NewHTTPClient(true)
14+ if http.DefaultTransport != orig {
15+ t.Fatal("NewHTTPClient must not mutate http.DefaultTransport")
16+ }
17+ tr, ok := client.Transport.(*http.Transport)
1618 if !ok {
17- t.Fatalf("expected *http.Transport, got %T", http.DefaultTransport)
19+ t.Fatalf("expected *http.Transport, got %T", client.Transport)
1820 }
1921 if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify {
20- t.Fatal("expected InsecureSkipVerify=true in TLS config")
22+ t.Fatal("expected scoped InsecureSkipVerify=true in TLS config")
2123 }
2224 }
2325
24-func TestInsecureClientReachesTLSServer(t *testing.T) {
26+func TestScopedInsecureClientReachesTLSServer(t *testing.T) {
2527 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2628 w.WriteHeader(http.StatusOK)
2729 }))
@@ -30,20 +32,23 @@ func TestInsecureClientReachesTLSServer(t *testing.T) {
3032 orig := http.DefaultTransport
3133 t.Cleanup(func() { http.DefaultTransport = orig })
3234
33- client := &http.Client{}
34-
35- if _, err := client.Get(srv.URL); err == nil {
36- t.Fatal("expected TLS error with default transport, got nil")
35+ secureClient := NewHTTPClient(false)
36+ if _, err := secureClient.Get(srv.URL); err == nil {
37+ t.Fatal("expected TLS error with secure client, got nil")
3738 }
3839
39- ApplyInsecureTLS()
40-
41- resp, err := client.Get(srv.URL)
40+ insecureClient := NewHTTPClient(true)
41+ resp, err := insecureClient.Get(srv.URL)
4242 if err != nil {
43- t.Fatalf("request failed after ApplyInsecureTLS: %v", err)
43+ t.Fatalf("request failed with scoped insecure client: %v", err)
4444 }
4545 resp.Body.Close()
4646 if resp.StatusCode != http.StatusOK {
4747 t.Fatalf("status=%d", resp.StatusCode)
4848 }
49+
50+ defaultClient := &http.Client{}
51+ if _, err := defaultClient.Get(srv.URL); err == nil {
52+ t.Fatal("default client must still reject self-signed TLS")
53+ }
4954 }
@@ -6,22 +6,24 @@ import (
6 "testing"6 "testing"
7 )7 )
8 8
9-func TestApplyInsecureTLSSetsDefaultTransport(t *testing.T) {9+func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) {
10 orig := http.DefaultTransport10 orig := http.DefaultTransport
11 t.Cleanup(func() { http.DefaultTransport = orig })11 t.Cleanup(func() { http.DefaultTransport = orig })
12 12
13- ApplyInsecureTLS()13+ client := NewHTTPClient(true)
14-14+ if http.DefaultTransport != orig {
15- tr, ok := http.DefaultTransport.(*http.Transport)15+ t.Fatal("NewHTTPClient must not mutate http.DefaultTransport")
16+ }
17+ tr, ok := client.Transport.(*http.Transport)
16 if !ok {18 if !ok {
17- t.Fatalf("expected *http.Transport, got %T", http.DefaultTransport)19+ t.Fatalf("expected *http.Transport, got %T", client.Transport)
18 }20 }
19 if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify {21 if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify {
20- t.Fatal("expected InsecureSkipVerify=true in TLS config")22+ t.Fatal("expected scoped InsecureSkipVerify=true in TLS config")
21 }23 }
22 }24 }
23 25
24-func TestInsecureClientReachesTLSServer(t *testing.T) {26+func TestScopedInsecureClientReachesTLSServer(t *testing.T) {
25 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {27 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
26 w.WriteHeader(http.StatusOK)28 w.WriteHeader(http.StatusOK)
27 }))29 }))
@@ -30,20 +32,23 @@ func TestInsecureClientReachesTLSServer(t *testing.T) {
30 orig := http.DefaultTransport32 orig := http.DefaultTransport
31 t.Cleanup(func() { http.DefaultTransport = orig })33 t.Cleanup(func() { http.DefaultTransport = orig })
32 34
33- client := &http.Client{}35+ secureClient := NewHTTPClient(false)
34-36+ if _, err := secureClient.Get(srv.URL); err == nil {
35- if _, err := client.Get(srv.URL); err == nil {37+ t.Fatal("expected TLS error with secure client, got nil")
36- t.Fatal("expected TLS error with default transport, got nil")
37 }38 }
38 39
39- ApplyInsecureTLS()40+ insecureClient := NewHTTPClient(true)
40-41+ resp, err := insecureClient.Get(srv.URL)
41- resp, err := client.Get(srv.URL)
42 if err != nil {42 if err != nil {
43- t.Fatalf("request failed after ApplyInsecureTLS: %v", err)43+ t.Fatalf("request failed with scoped insecure client: %v", err)
44 }44 }
45 resp.Body.Close()45 resp.Body.Close()
46 if resp.StatusCode != http.StatusOK {46 if resp.StatusCode != http.StatusOK {
47 t.Fatalf("status=%d", resp.StatusCode)47 t.Fatalf("status=%d", resp.StatusCode)
48 }48 }
49+
50+ defaultClient := &http.Client{}
51+ if _, err := defaultClient.Get(srv.URL); err == nil {
52+ t.Fatal("default client must still reject self-signed TLS")
53+ }
49 }54 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close