From 5859d66f1a95e2520c85d45e1141989db539ba61 Mon Sep 17 00:00:00 2001 From: s3rj1k Date: Tue, 16 Jun 2026 00:33:11 +0200 Subject: [PATCH 1/3] Add `--insecure` flag to skip TLS verification --- packages/agent/args.go | 6 +++ packages/agent/build.go | 5 +++ packages/agent/build_test.go | 59 ++++++++++++++++++++++++++++ packages/agent/config.go | 3 ++ packages/provider/httpclient.go | 16 ++++++++ packages/provider/httpclient_test.go | 49 +++++++++++++++++++++++ 6 files changed, 138 insertions(+) create mode 100644 packages/provider/httpclient.go create mode 100644 packages/provider/httpclient_test.go diff --git a/packages/agent/args.go b/packages/agent/args.go index 6ec93b9..c201b69 100644 --- a/packages/agent/args.go +++ b/packages/agent/args.go @@ -74,6 +74,9 @@ type Args struct { // skill discovery, including built-ins. WithSkills bool + // InsecureTLS skips TLS verification for custom inference endpoints. + InsecureTLS bool + // NoYolo turns on per-tool confirmation. Before each tool // invocation the TUI prompts the user with the tool name + args // and waits for an explicit yes/no. The user can also pick @@ -190,6 +193,8 @@ func ParseArgs(in []string) (Args, error) { case "--with-skills", "--with-skill": // Deprecated no-op: user skills are loaded by default. a.WithSkills = true + case "--insecure": + a.InsecureTLS = true case "--no-yolo": a.NoYolo = true case "--reasoning": @@ -373,6 +378,7 @@ func PrintHelp(version string) { row{"--model ID", "model id (see --list-models)"}, row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, row{"--base-url URL", "override provider api base url"}, + row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"}, row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"}, row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"}, ) diff --git a/packages/agent/build.go b/packages/agent/build.go index 7b54b42..23d8301 100644 --- a/packages/agent/build.go +++ b/packages/agent/build.go @@ -420,6 +420,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { args.BaseURL = "http://localhost:11434" } + provider.InsecureSkipVerify = (args.InsecureTLS || cfg.Insecure) && args.BaseURL != "" + if provider.InsecureSkipVerify { + provider.ApplyInsecureTLS() + } + // If the model has a base URL, credentials are optional (local // models like ollama don't need real API keys). if resolvedModel.BaseURL != "" && credErr != nil { diff --git a/packages/agent/build_test.go b/packages/agent/build_test.go index 2b06a7a..1ce8224 100644 --- a/packages/agent/build_test.go +++ b/packages/agent/build_test.go @@ -211,3 +211,62 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) { } } } + +func TestResolveInsecureOnlyWithCustomBaseURL(t *testing.T) { + orig := provider.InsecureSkipVerify + t.Cleanup(func() { provider.InsecureSkipVerify = orig }) + + t.Setenv("ZOT_HOME", t.TempDir()) + t.Setenv("OPENAI_API_KEY", "test-key") + + // no --base-url: must stay false even with --insecure. + provider.InsecureSkipVerify = false + _, err := Resolve(Args{Provider: "openai", InsecureTLS: true}, false) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if provider.InsecureSkipVerify { + t.Fatal("InsecureSkipVerify must not be set without a custom base URL") + } + + // --base-url + --insecure: must be true. + provider.InsecureSkipVerify = false + _, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !provider.InsecureSkipVerify { + t.Fatal("InsecureSkipVerify must be set with --insecure and --base-url") + } +} + +func TestResolveInsecureFromConfig(t *testing.T) { + orig := provider.InsecureSkipVerify + t.Cleanup(func() { provider.InsecureSkipVerify = orig }) + + t.Setenv("ZOT_HOME", t.TempDir()) + t.Setenv("OPENAI_API_KEY", "test-key") + if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil { + t.Fatal(err) + } + + // no --base-url: must stay false. + provider.InsecureSkipVerify = false + _, err := Resolve(Args{Provider: "openai"}, false) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if provider.InsecureSkipVerify { + t.Fatal("InsecureSkipVerify must not be set without a custom base URL") + } + + // --base-url: must be true. + provider.InsecureSkipVerify = false + _, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !provider.InsecureSkipVerify { + t.Fatal("InsecureSkipVerify must be set when config insecure=true and --base-url is provided") + } +} diff --git a/packages/agent/config.go b/packages/agent/config.go index ed6ac9f..a29d9a1 100644 --- a/packages/agent/config.go +++ b/packages/agent/config.go @@ -45,6 +45,9 @@ type Config struct { // which is on; false shows ignored entries. Toggle from /settings. RespectGitignore *bool `json:"respect_gitignore,omitempty"` + // Insecure skips TLS verification for custom inference endpoints. + Insecure bool `json:"insecure,omitempty"` + // LastChangelogShown is the version whose release-notes // dialog the user has already seen. When the running binary's // version differs, the next interactive run shows the diff --git a/packages/provider/httpclient.go b/packages/provider/httpclient.go new file mode 100644 index 0000000..2343fa4 --- /dev/null +++ b/packages/provider/httpclient.go @@ -0,0 +1,16 @@ +package provider + +import ( + "crypto/tls" + "net/http" +) + +// InsecureSkipVerify disables TLS cert verification for inference connections. +var InsecureSkipVerify bool + +// ApplyInsecureTLS replaces http.DefaultTransport to skip TLS cert verification. +func ApplyInsecureTLS() { + http.DefaultTransport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + } +} diff --git a/packages/provider/httpclient_test.go b/packages/provider/httpclient_test.go new file mode 100644 index 0000000..6635960 --- /dev/null +++ b/packages/provider/httpclient_test.go @@ -0,0 +1,49 @@ +package provider + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestApplyInsecureTLSSetsDefaultTransport(t *testing.T) { + orig := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = orig }) + + ApplyInsecureTLS() + + tr, ok := http.DefaultTransport.(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", http.DefaultTransport) + } + if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify { + t.Fatal("expected InsecureSkipVerify=true in TLS config") + } +} + +func TestInsecureClientReachesTLSServer(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + orig := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = orig }) + + client := &http.Client{} + + if _, err := client.Get(srv.URL); err == nil { + t.Fatal("expected TLS error with default transport, got nil") + } + + ApplyInsecureTLS() + + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("request failed after ApplyInsecureTLS: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status=%d", resp.StatusCode) + } +} From ab7fb37046c36ec7f804520e1cbbda674a1f9969 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Tue, 16 Jun 2026 07:41:38 +0200 Subject: [PATCH 2/3] 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 --- README.md | 1 + packages/agent/build.go | 96 ++++++++++++++++------------ packages/agent/build_test.go | 60 +++++++++-------- packages/provider/httpclient.go | 48 ++++++++++++-- packages/provider/httpclient_test.go | 35 +++++----- 5 files changed, 150 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 67f02e1..3c9fb8f 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ zot --help | `--model ` | Pick the model (see `--list-models`). | | `--api-key ` | Override the API key. | | `--base-url ` | Override the provider base URL (tests, self-hosted). | +| `--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. | | `--system-prompt ` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | `--append-system-prompt ` | Append text to the system prompt (repeatable). | | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). | diff --git a/packages/agent/build.go b/packages/agent/build.go index 23d8301..d5ff04d 100644 --- a/packages/agent/build.go +++ b/packages/agent/build.go @@ -23,6 +23,7 @@ type Resolved struct { AuthMethod string // "apikey" | "oauth" | "" (no credential yet) AccountID string // ChatGPT account id (for openai oauth), "" otherwise BaseURL string + InsecureTLS bool CWD string Reasoning string Temperature *float32 @@ -409,6 +410,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { model = fm.ID } + explicitBaseURL := args.BaseURL != "" + // If the model defines a base URL (e.g. local ollama) and the // user didn't pass --base-url, use the model's URL. For ollama, // keep http://localhost:11434 as a fallback only after the model @@ -420,10 +423,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { args.BaseURL = "http://localhost:11434" } - provider.InsecureSkipVerify = (args.InsecureTLS || cfg.Insecure) && args.BaseURL != "" - if provider.InsecureSkipVerify { - provider.ApplyInsecureTLS() - } + // Insecure TLS is intentionally scoped to explicit custom endpoints. + // Built-in provider base URLs, auth calls, and model discovery keep normal + // certificate verification even when --insecure is present. + insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL // If the model has a base URL, credentials are optional (local // models like ollama don't need real API keys). @@ -512,6 +515,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { AuthMethod: method, AccountID: accountID, BaseURL: args.BaseURL, + InsecureTLS: insecureTLS, CWD: args.CWD, Reasoning: reasoning, Temperature: temperature, @@ -639,92 +643,100 @@ func (r Resolved) NewClient() provider.Client { if !r.HasCredential() { panic("NewClient called without credential; check HasCredential first") } + wrap := r.withHTTPClient switch r.Provider { case "ollama": - return provider.NewOpenAI(r.Credential, r.BaseURL) + return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) case "kimi": // kimi-coding speaks anthropic-messages on api.kimi.com/coding. // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. - inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()) + inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())) if r.AuthMethod == "oauth" { return r.wrapWithRefresh(inner) } return inner case "moonshotai": - return provider.NewMoonshot(r.Credential, r.BaseURL) + return wrap(provider.NewMoonshot(r.Credential, r.BaseURL)) case "moonshotai-cn": - return provider.NewMoonshotCN(r.Credential, r.BaseURL) + return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL)) case "deepseek": - return provider.NewDeepSeek(r.Credential, r.BaseURL) + return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL)) case "openai": - return provider.NewOpenAI(r.Credential, r.BaseURL) + return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) case "openai-codex": - inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) + inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)) return r.wrapWithRefresh(inner) case "openai-responses": // Public OpenAI Responses API (api.openai.com/v1/responses) via // API key. Separate provider from `openai` (Chat Completions) and // from `openai-codex` (ChatGPT subscription OAuth). - return provider.NewOpenAIResponses(r.Credential, r.BaseURL) + return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL)) case "google": - return provider.NewGemini(r.Credential, r.BaseURL) + return wrap(provider.NewGemini(r.Credential, r.BaseURL)) case "cerebras": - return provider.NewCerebras(r.Credential, r.BaseURL) + return wrap(provider.NewCerebras(r.Credential, r.BaseURL)) case "groq": - return provider.NewGroq(r.Credential, r.BaseURL) + return wrap(provider.NewGroq(r.Credential, r.BaseURL)) case "xai": - return provider.NewXAI(r.Credential, r.BaseURL) + return wrap(provider.NewXAI(r.Credential, r.BaseURL)) case "together": - return provider.NewTogether(r.Credential, r.BaseURL) + return wrap(provider.NewTogether(r.Credential, r.BaseURL)) case "huggingface": - return provider.NewHuggingFace(r.Credential, r.BaseURL) + return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL)) case "openrouter": - return provider.NewOpenRouter(r.Credential, r.BaseURL) + return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL)) case "zai": - return provider.NewZAI(r.Credential, r.BaseURL) + return wrap(provider.NewZAI(r.Credential, r.BaseURL)) case "xiaomi": - return provider.NewXiaomi(r.Credential, r.BaseURL) + return wrap(provider.NewXiaomi(r.Credential, r.BaseURL)) case "xiaomi-token-plan-ams": - return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL) + return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)) case "xiaomi-token-plan-cn": - return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL) + return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)) case "xiaomi-token-plan-sgp": - return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL) + return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)) case "opencode": - return provider.NewOpenCode(r.Credential, r.BaseURL) + return wrap(provider.NewOpenCode(r.Credential, r.BaseURL)) case "opencode-go": - return provider.NewOpenCodeGo(r.Credential, r.BaseURL) + return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL)) case "minimax": - return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL) + return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)) case "minimax-cn": - return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL) + return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)) case "fireworks": - return provider.NewFireworksAnthropic(r.Credential, r.BaseURL) + return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL)) case "vercel-ai-gateway": - return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL) + return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)) case "mistral": - return provider.NewMistral(r.Credential, r.BaseURL) + return wrap(provider.NewMistral(r.Credential, r.BaseURL)) case "amazon-bedrock": - return provider.NewBedrock(r.Credential, r.BaseURL) + return wrap(provider.NewBedrock(r.Credential, r.BaseURL)) case "google-vertex": - return provider.NewGoogleVertex(r.Credential, r.BaseURL) + return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL)) case "azure-openai-responses": - return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL) + return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)) case "github-copilot": - return provider.NewGithubCopilot(r.Credential, r.BaseURL) + return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL)) case "cloudflare-workers-ai": - return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL) + return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)) case "cloudflare-ai-gateway": - return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL) + return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)) default: if r.AuthMethod == "oauth" { - inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL) + inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL)) return r.wrapWithRefresh(inner) } - return provider.NewAnthropic(r.Credential, r.BaseURL) + return wrap(provider.NewAnthropic(r.Credential, r.BaseURL)) } } +func (r Resolved) withHTTPClient(c provider.Client) provider.Client { + if !r.InsecureTLS { + return c + } + return provider.WithHTTPClient(c, provider.NewHTTPClient(true)) +} + // wrapWithRefresh wraps an OAuth client so the access token is // refreshed automatically before each API call. Without this, long // sessions (hours) silently fail when the 1-hour token expires. @@ -748,12 +760,12 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { factory := func(token string) provider.Client { switch provName { case "openai-codex": - return provider.NewOpenAICodex(token, accountID, baseURL) + return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL)) case "kimi": // anthropic-messages on api.kimi.com/coding. - return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()) + return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())) default: - return provider.NewAnthropicOAuth(token, baseURL) + return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL)) } } diff --git a/packages/agent/build_test.go b/packages/agent/build_test.go index 1ce8224..d1fdbf1 100644 --- a/packages/agent/build_test.go +++ b/packages/agent/build_test.go @@ -1,6 +1,7 @@ package agent import ( + "net/http" "os" "path/filepath" "strings" @@ -212,37 +213,35 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) { } } -func TestResolveInsecureOnlyWithCustomBaseURL(t *testing.T) { - orig := provider.InsecureSkipVerify - t.Cleanup(func() { provider.InsecureSkipVerify = orig }) +func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) { + orig := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = orig }) t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("OPENAI_API_KEY", "test-key") - // no --base-url: must stay false even with --insecure. - provider.InsecureSkipVerify = false - _, err := Resolve(Args{Provider: "openai", InsecureTLS: true}, false) + resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false) if err != nil { t.Fatalf("Resolve: %v", err) } - if provider.InsecureSkipVerify { - t.Fatal("InsecureSkipVerify must not be set without a custom base URL") + if resolved.InsecureTLS { + t.Fatal("InsecureTLS must not be set for built-in provider base URLs") } + assertDefaultTransportStillSecure(t) - // --base-url + --insecure: must be true. - provider.InsecureSkipVerify = false - _, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false) + resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false) if err != nil { t.Fatalf("Resolve: %v", err) } - if !provider.InsecureSkipVerify { - t.Fatal("InsecureSkipVerify must be set with --insecure and --base-url") + if !resolved.InsecureTLS { + t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url") } + assertDefaultTransportStillSecure(t) } -func TestResolveInsecureFromConfig(t *testing.T) { - orig := provider.InsecureSkipVerify - t.Cleanup(func() { provider.InsecureSkipVerify = orig }) +func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) { + orig := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = orig }) t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("OPENAI_API_KEY", "test-key") @@ -250,23 +249,32 @@ func TestResolveInsecureFromConfig(t *testing.T) { t.Fatal(err) } - // no --base-url: must stay false. - provider.InsecureSkipVerify = false - _, err := Resolve(Args{Provider: "openai"}, false) + resolved, err := Resolve(Args{Provider: "openai"}, false) if err != nil { t.Fatalf("Resolve: %v", err) } - if provider.InsecureSkipVerify { - t.Fatal("InsecureSkipVerify must not be set without a custom base URL") + if resolved.InsecureTLS { + t.Fatal("InsecureTLS must not be set without a custom base URL") } + assertDefaultTransportStillSecure(t) - // --base-url: must be true. - provider.InsecureSkipVerify = false - _, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false) + resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false) if err != nil { t.Fatalf("Resolve: %v", err) } - if !provider.InsecureSkipVerify { - t.Fatal("InsecureSkipVerify must be set when config insecure=true and --base-url is provided") + if !resolved.InsecureTLS { + t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided") + } + assertDefaultTransportStillSecure(t) +} + +func assertDefaultTransportStillSecure(t *testing.T) { + t.Helper() + tr, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return + } + if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify { + t.Fatal("http.DefaultTransport must not be made insecure") } } diff --git a/packages/provider/httpclient.go b/packages/provider/httpclient.go index 2343fa4..c90cd15 100644 --- a/packages/provider/httpclient.go +++ b/packages/provider/httpclient.go @@ -5,12 +5,46 @@ import ( "net/http" ) -// InsecureSkipVerify disables TLS cert verification for inference connections. -var InsecureSkipVerify bool - -// ApplyInsecureTLS replaces http.DefaultTransport to skip TLS cert verification. -func ApplyInsecureTLS() { - http.DefaultTransport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec +// NewHTTPClient returns a provider HTTP client. When insecureTLS is true, +// only this client skips TLS certificate verification. The process-wide +// default transport is left untouched so auth, discovery, and other providers +// keep normal certificate validation. +func NewHTTPClient(insecureTLS bool) *http.Client { + if !insecureTLS { + return &http.Client{Timeout: 0} } + tr, ok := http.DefaultTransport.(*http.Transport) + if ok { + tr = tr.Clone() + } else { + tr = &http.Transport{} + } + if tr.TLSClientConfig != nil { + tr.TLSClientConfig = tr.TLSClientConfig.Clone() + } else { + tr.TLSClientConfig = &tls.Config{} + } + tr.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec + return &http.Client{Timeout: 0, Transport: tr} +} + +// WithHTTPClient scopes an HTTP client to a concrete provider client. +// Unsupported clients are returned unchanged. +func WithHTTPClient(c Client, httpClient *http.Client) Client { + if httpClient == nil { + return c + } + switch v := c.(type) { + case *openaiClient: + v.http = httpClient + case *anthropicClient: + v.http = httpClient + case *geminiClient: + v.http = httpClient + case *bedrockClient: + v.http = httpClient + case *renamedClient: + v.inner = WithHTTPClient(v.inner, httpClient) + } + return c } diff --git a/packages/provider/httpclient_test.go b/packages/provider/httpclient_test.go index 6635960..9be5953 100644 --- a/packages/provider/httpclient_test.go +++ b/packages/provider/httpclient_test.go @@ -6,22 +6,24 @@ import ( "testing" ) -func TestApplyInsecureTLSSetsDefaultTransport(t *testing.T) { +func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) { orig := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = orig }) - ApplyInsecureTLS() - - tr, ok := http.DefaultTransport.(*http.Transport) + client := NewHTTPClient(true) + if http.DefaultTransport != orig { + t.Fatal("NewHTTPClient must not mutate http.DefaultTransport") + } + tr, ok := client.Transport.(*http.Transport) if !ok { - t.Fatalf("expected *http.Transport, got %T", http.DefaultTransport) + t.Fatalf("expected *http.Transport, got %T", client.Transport) } if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify { - t.Fatal("expected InsecureSkipVerify=true in TLS config") + t.Fatal("expected scoped InsecureSkipVerify=true in TLS config") } } -func TestInsecureClientReachesTLSServer(t *testing.T) { +func TestScopedInsecureClientReachesTLSServer(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -30,20 +32,23 @@ func TestInsecureClientReachesTLSServer(t *testing.T) { orig := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = orig }) - client := &http.Client{} - - if _, err := client.Get(srv.URL); err == nil { - t.Fatal("expected TLS error with default transport, got nil") + secureClient := NewHTTPClient(false) + if _, err := secureClient.Get(srv.URL); err == nil { + t.Fatal("expected TLS error with secure client, got nil") } - ApplyInsecureTLS() - - resp, err := client.Get(srv.URL) + insecureClient := NewHTTPClient(true) + resp, err := insecureClient.Get(srv.URL) if err != nil { - t.Fatalf("request failed after ApplyInsecureTLS: %v", err) + t.Fatalf("request failed with scoped insecure client: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status=%d", resp.StatusCode) } + + defaultClient := &http.Client{} + if _, err := defaultClient.Get(srv.URL); err == nil { + t.Fatal("default client must still reject self-signed TLS") + } } From 1cc654ebbfceb15f4f56e61011d1a02ea1825a10 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Tue, 16 Jun 2026 07:46:29 +0200 Subject: [PATCH 3/3] Recognize Esc and other control keys in kitty keyboard mode Enabling the kitty keyboard protocol for Shift+Enter made terminals report Esc as CSI 27 u, which the CSI-u parser dropped as KeyUnknown, so Esc stopped aborting the agent. Map kitty control codepoints (Esc=27, Tab=9, Backspace=127/8) back to their dedicated keys. --- packages/tui/input.go | 13 +++++++++++++ packages/tui/input_test.go | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/tui/input.go b/packages/tui/input.go index 3fdf463..6c5fe98 100644 --- a/packages/tui/input.go +++ b/packages/tui/input.go @@ -342,9 +342,22 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { shift := bits&1 != 0 alt := bits&2 != 0 ctrl := bits&4 != 0 + // Kitty keyboard protocol (CSI ... u) reports control keys as their + // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the + // enhanced-mode handling these arrive as raw bytes; with it enabled + // they come through here, so map them back to their dedicated keys. switch code { case 13: return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true + case 27: + return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true + case 9: + if shift { + return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true + } + return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true + case 127, 8: + return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true } if ctrl { switch code { diff --git a/packages/tui/input_test.go b/packages/tui/input_test.go index dd1d03d..b071424 100644 --- a/packages/tui/input_test.go +++ b/packages/tui/input_test.go @@ -30,6 +30,25 @@ func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { } } +func TestReaderParsesCSIUEsc(t *testing.T) { + k := readKey(t, "\x1b[27u") + if k.Kind != KeyEsc { + t.Fatalf("Read kind=%v, want esc", k.Kind) + } +} + +func TestReaderParsesCSIUTabAndBackspace(t *testing.T) { + if k := readKey(t, "\x1b[9u"); k.Kind != KeyTab { + t.Fatalf("Read kind=%v, want tab", k.Kind) + } + if k := readKey(t, "\x1b[9;2u"); k.Kind != KeyShiftTab { + t.Fatalf("Read kind=%v, want shift-tab", k.Kind) + } + if k := readKey(t, "\x1b[127u"); k.Kind != KeyBackspace { + t.Fatalf("Read kind=%v, want backspace", k.Kind) + } +} + func TestReaderParsesSGRMouseWheel(t *testing.T) { cases := []struct { seq string