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/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 d2555f4..154cbb0 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 @@ -455,6 +456,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 @@ -466,6 +469,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { args.BaseURL = "http://localhost:11434" } + // 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). if resolvedModel.BaseURL != "" && credErr != nil { @@ -553,6 +561,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, @@ -680,83 +689,84 @@ 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: // Custom providers: choose wire format from the models.json api field. if cfg, ok := provider.CustomProviders()[r.Provider]; ok { @@ -768,13 +778,20 @@ func (r Resolved) NewClient() provider.Client { } } 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. @@ -798,12 +815,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 2b06a7a..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" @@ -211,3 +212,69 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) { } } } + +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") + + resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if resolved.InsecureTLS { + t.Fatal("InsecureTLS must not be set for built-in provider base URLs") + } + assertDefaultTransportStillSecure(t) + + resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !resolved.InsecureTLS { + t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url") + } + assertDefaultTransportStillSecure(t) +} + +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") + if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil { + t.Fatal(err) + } + + resolved, err := Resolve(Args{Provider: "openai"}, false) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if resolved.InsecureTLS { + t.Fatal("InsecureTLS must not be set without a custom base URL") + } + assertDefaultTransportStillSecure(t) + + resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + 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/agent/config.go b/packages/agent/config.go index 3ee769c..60d285d 100644 --- a/packages/agent/config.go +++ b/packages/agent/config.go @@ -46,6 +46,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..c90cd15 --- /dev/null +++ b/packages/provider/httpclient.go @@ -0,0 +1,50 @@ +package provider + +import ( + "crypto/tls" + "net/http" +) + +// 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 new file mode 100644 index 0000000..9be5953 --- /dev/null +++ b/packages/provider/httpclient_test.go @@ -0,0 +1,54 @@ +package provider + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) { + orig := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = orig }) + + 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", client.Transport) + } + if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify { + t.Fatal("expected scoped InsecureSkipVerify=true in TLS config") + } +} + +func TestScopedInsecureClientReachesTLSServer(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 }) + + secureClient := NewHTTPClient(false) + if _, err := secureClient.Get(srv.URL); err == nil { + t.Fatal("expected TLS error with secure client, got nil") + } + + insecureClient := NewHTTPClient(true) + resp, err := insecureClient.Get(srv.URL) + if err != nil { + 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") + } +} 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