diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index 51870dc963..a4e2138f66 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -72,7 +72,10 @@ func newConfigCommand() *cobra.Command { } func newConfigSetCommand() *cobra.Command { - var opts llm.SetOptions + var ( + opts llm.SetOptions + tlsSkipVerify bool + ) cmd := &cobra.Command{ Use: "set", @@ -85,7 +88,10 @@ Example: --issuer https://auth.example.com \ --client-id my-client-id`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { + if cmd.Flags().Changed("tls-skip-verify") { + opts.TLSSkipVerify = &tlsSkipVerify + } return config.UpdateConfig(func(c *config.Config) error { return c.LLM.SetFields(opts) }) @@ -98,6 +104,8 @@ Example: cmd.Flags().StringVar(&opts.Audience, "audience", "", "OIDC audience (optional)") cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "Localhost proxy listen port (omit to keep current; default: 14000)") cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (omit to keep current; default: ephemeral)") + cmd.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false, + "Skip TLS certificate verification for the upstream gateway (local dev only; use --tls-skip-verify=false to clear)") return cmd } @@ -204,7 +212,10 @@ func buildLLMTokenSource(cfg *llm.Config, interactive bool) (*llm.TokenSource, e // ── setup / teardown ───────────────────────────────────────────────────────── func newLLMSetupCommand() *cobra.Command { - var opts llm.SetOptions + var ( + opts llm.SetOptions + tlsSkipVerify bool + ) cmd := &cobra.Command{ Use: "setup", @@ -225,6 +236,9 @@ lets you combine "config set" and "setup" into a single command. Run "thv llm teardown" to revert all changes.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + if cmd.Flags().Changed("tls-skip-verify") { + opts.TLSSkipVerify = &tlsSkipVerify + } cm, err := client.NewClientManager() if err != nil { return fmt.Errorf("initializing client manager: %w", err) @@ -242,6 +256,11 @@ Run "thv llm teardown" to revert all changes.`, cmd.Flags().StringVar(&opts.Audience, "audience", "", "OIDC audience (optional)") cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "Localhost proxy listen port (omit to keep current; default: 14000)") cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (omit to keep current; default: ephemeral)") + cmd.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false, + "Skip TLS certificate verification for the upstream gateway (local dev only). "+ + "For direct-mode tools (Claude Code, Gemini CLI) this sets NODE_TLS_REJECT_UNAUTHORIZED=0, "+ + "disabling TLS for ALL of that tool's outbound connections. "+ + "For proxy-mode tools only the proxy-to-gateway connection is affected.") return cmd } @@ -334,6 +353,7 @@ func (a *clientManagerAdapter) ConfigureLLMGateway(clientType string, cfg llm.To GatewayURL: cfg.GatewayURL, ProxyBaseURL: cfg.ProxyBaseURL, TokenHelperCommand: cfg.TokenHelperCommand, + TLSSkipVerify: cfg.TLSSkipVerify, }) } @@ -370,7 +390,9 @@ func newLLMProxyCommand() *cobra.Command { } func newLLMProxyStartCommand() *cobra.Command { - return &cobra.Command{ + var tlsSkipVerify bool + + cmd := &cobra.Command{ Use: "start", Short: "Start the LLM gateway localhost proxy", Long: `Start a localhost reverse proxy that injects fresh OIDC tokens for AI tools @@ -392,9 +414,20 @@ To run it in the background, use your shell or a process manager: return fmt.Errorf("LLM gateway configuration is invalid: %w", err) } + // --tls-skip-verify overrides the stored config; if not provided, fall + // back to whatever was persisted by "thv llm setup" or "config set". + if cmd.Flags().Changed("tls-skip-verify") { + llmCfg.TLSSkipVerify = tlsSkipVerify + } + return runLLMProxyForeground(cmd.Context(), &llmCfg) }, } + + cmd.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false, + "Skip TLS certificate verification for the upstream gateway (overrides stored config; local dev only)") + + return cmd } // runLLMProxyForeground builds a TokenSource and starts the proxy in this process. @@ -403,7 +436,7 @@ func runLLMProxyForeground(ctx context.Context, llmCfg *llm.Config) error { if err != nil { return err } - p, err := llmproxy.New(llmCfg, ts) + p, err := llmproxy.New(llmCfg, ts, llmproxy.WithTLSSkipVerify(llmCfg.TLSSkipVerify)) if err != nil { return err } diff --git a/pkg/client/config.go b/pkg/client/config.go index 3c0ef2d2d5..88e7ea8e19 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -157,10 +157,15 @@ const ( // top-level key names (e.g. "/cursor.general.openAIBaseURL") are treated as // literals by hujson.Patch. // ValueField names which LLMApplyConfig field to write: "GatewayURL", -// "ProxyBaseURL", "TokenHelperCommand", or "PlaceholderAPIKey" (constant "thv-proxy"). +// "ProxyBaseURL", "TokenHelperCommand", "PlaceholderAPIKey" (constant "thv-proxy"), +// or "NodeTLSRejectUnauthorized" (writes "0" when TLSSkipVerify is true). +// ClearWhenEmpty: when true and the resolved value is empty, the key is removed +// from the settings file rather than skipped. Use for conditional keys like +// NODE_TLS_REJECT_UNAUTHORIZED that must be cleaned up when the flag is cleared. type LLMGatewayKeySpec struct { - JSONPointer string // RFC 6901 path - ValueField string // "GatewayURL" | "ProxyBaseURL" | "TokenHelperCommand" | "PlaceholderAPIKey" + JSONPointer string // RFC 6901 path + ValueField string // "GatewayURL" | "ProxyBaseURL" | "TokenHelperCommand" | "PlaceholderAPIKey" | "NodeTLSRejectUnauthorized" + ClearWhenEmpty bool // remove the key when the resolved value is empty } // clientAppConfig represents a configuration path for a supported MCP client. @@ -457,6 +462,9 @@ var supportedClientIntegrations = []clientAppConfig{ LLMGatewayKeys: []LLMGatewayKeySpec{ {JSONPointer: "/apiKeyHelper", ValueField: "TokenHelperCommand"}, {JSONPointer: "/env/ANTHROPIC_BASE_URL", ValueField: "GatewayURL"}, + // NODE_TLS_REJECT_UNAUTHORIZED is only written when --tls-skip-verify is set. + // ClearWhenEmpty ensures it is removed when the flag is later cleared. + {JSONPointer: "/env/NODE_TLS_REJECT_UNAUTHORIZED", ValueField: "NodeTLSRejectUnauthorized", ClearWhenEmpty: true}, }, }, { @@ -836,6 +844,9 @@ var supportedClientIntegrations = []clientAppConfig{ LLMGatewayKeys: []LLMGatewayKeySpec{ {JSONPointer: "/auth/tokenCommand", ValueField: "TokenHelperCommand"}, {JSONPointer: "/baseUrl", ValueField: "GatewayURL"}, + // NODE_TLS_REJECT_UNAUTHORIZED is only written when --tls-skip-verify is set. + // ClearWhenEmpty ensures it is removed when the flag is later cleared. + {JSONPointer: "/env/NODE_TLS_REJECT_UNAUTHORIZED", ValueField: "NodeTLSRejectUnauthorized", ClearWhenEmpty: true}, }, }, { diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go index da897d44ff..9f61be2965 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -32,6 +32,7 @@ type LLMApplyConfig struct { GatewayURL string // direct-mode: URL of the upstream LLM gateway ProxyBaseURL string // proxy-mode: URL of the localhost reverse proxy TokenHelperCommand string // direct-mode: command that prints a fresh access token + TLSSkipVerify bool // when true, instruct the tool to skip TLS verification } // ConfigureLLMGateway patches the tool's LLM-gateway settings file with cfg @@ -65,27 +66,8 @@ func (cm *ClientManager) ConfigureLLMGateway(clientType ClientApp, cfg LLMApplyC return fmt.Errorf("parsing %s: %w", path, err) } - // Ensure every intermediate ancestor object exists before patching. - // e.g. "/a/b/c" requires "/a" and "/a/b" to already be present. - for _, spec := range appCfg.LLMGatewayKeys { - if err := ensureLLMAncestors(&v, spec.JSONPointer, path); err != nil { - return err - } - } - - for _, spec := range appCfg.LLMGatewayKeys { - value := llmValueForSpec(spec.ValueField, cfg) - valueJSON, err := json.Marshal(value) - if err != nil { - return fmt.Errorf("marshaling value for %s: %w", spec.JSONPointer, err) - } - patchDoc, err := json.Marshal([]llmPatchOp{{Op: "add", Path: spec.JSONPointer, Value: valueJSON}}) - if err != nil { - return fmt.Errorf("marshaling patch for %s: %w", spec.JSONPointer, err) - } - if err := v.Patch(patchDoc); err != nil { - return fmt.Errorf("patching %s in %s: %w", spec.JSONPointer, path, err) - } + if err := applyLLMGatewayKeys(&v, appCfg.LLMGatewayKeys, cfg, path); err != nil { + return err } formatted, err := hujson.Format(v.Pack()) @@ -100,6 +82,74 @@ func (cm *ClientManager) ConfigureLLMGateway(clientType ClientApp, cfg LLMApplyC return path, nil } +// applyLLMGatewayKeys writes or removes each key spec into v according to cfg. +// Specs with ClearWhenEmpty=true are removed when their resolved value is empty, +// allowing conditional keys (e.g. NODE_TLS_REJECT_UNAUTHORIZED) to be cleaned +// up when the associated flag is cleared. +func applyLLMGatewayKeys(v *hujson.Value, specs []LLMGatewayKeySpec, cfg LLMApplyConfig, filePath string) error { + // Ensure ancestors only for specs that will be written (not removed). + for _, spec := range specs { + if spec.ClearWhenEmpty && llmValueForSpec(spec.ValueField, cfg) == "" { + continue + } + if err := ensureLLMAncestors(v, spec.JSONPointer, filePath); err != nil { + return err + } + } + + // Standardize once for existence checks in the remove path. + standardized, err := hujson.Standardize(v.Pack()) + if err != nil { + return fmt.Errorf("standardizing %s: %w", filePath, err) + } + + for _, spec := range specs { + value := llmValueForSpec(spec.ValueField, cfg) + if spec.ClearWhenEmpty && value == "" { + if err := removeLLMKey(v, spec.JSONPointer, filePath, standardized); err != nil { + return err + } + continue + } + if err := addLLMKey(v, spec.JSONPointer, value, filePath); err != nil { + return err + } + } + return nil +} + +// removeLLMKey removes the key at ptr from v if it exists. standardized is +// pre-computed hujson.Standardize output used for the existence check. +func removeLLMKey(v *hujson.Value, ptr, filePath string, standardized []byte) error { + if !jsonPointerExists(standardized, ptr) { + return nil + } + patchDoc, err := json.Marshal([]llmPatchOp{{Op: "remove", Path: ptr}}) + if err != nil { + return fmt.Errorf("marshaling remove patch for %s: %w", ptr, err) + } + if err := v.Patch(patchDoc); err != nil { + return fmt.Errorf("removing %s from %s: %w", ptr, filePath, err) + } + return nil +} + +// addLLMKey writes value to the key at ptr inside v. +func addLLMKey(v *hujson.Value, ptr, value, filePath string) error { + valueJSON, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshaling value for %s: %w", ptr, err) + } + patchDoc, err := json.Marshal([]llmPatchOp{{Op: "add", Path: ptr, Value: valueJSON}}) + if err != nil { + return fmt.Errorf("marshaling patch for %s: %w", ptr, err) + } + if err := v.Patch(patchDoc); err != nil { + return fmt.Errorf("patching %s in %s: %w", ptr, filePath, err) + } + return nil +} + // RevertLLMGateway removes the LLM gateway keys from the tool's settings file. // If the file does not exist the call is a no-op. Comments and trailing commas // in JSONC settings files are preserved. @@ -216,6 +266,8 @@ func (cm *ClientManager) buildLLMSettingsPath(cfg *clientAppConfig) string { } // llmValueForSpec returns the config value corresponding to the ValueField name. +// For "NodeTLSRejectUnauthorized", returns "0" when TLSSkipVerify is true, or "" +// when false (which triggers removal when ClearWhenEmpty is set on the spec). func llmValueForSpec(valueField string, cfg LLMApplyConfig) string { switch valueField { case "GatewayURL": @@ -226,6 +278,11 @@ func llmValueForSpec(valueField string, cfg LLMApplyConfig) string { return cfg.TokenHelperCommand case "PlaceholderAPIKey": return llmPlaceholderAPIKey + case "NodeTLSRejectUnauthorized": + if cfg.TLSSkipVerify { + return "0" + } + return "" default: return valueField // treat unknown field names as literal values } diff --git a/pkg/client/llm_gateway_test.go b/pkg/client/llm_gateway_test.go index aead35b241..229ee2f3e2 100644 --- a/pkg/client/llm_gateway_test.go +++ b/pkg/client/llm_gateway_test.go @@ -537,6 +537,91 @@ func TestRealClientConfigs_LLMBinaryNames(t *testing.T) { } } +// ── TLSSkipVerify / NodeTLSRejectUnauthorized / ClearWhenEmpty ─────────────── + +func newTLSTestManager(t *testing.T) (*ClientManager, string) { + t.Helper() + home := t.TempDir() + cfgs := LLMTestIntegrations([]LLMTestEntry{{ + ClientType: ClaudeCode, + Mode: "direct", + SettingsDir: []string{".claude"}, + SettingsFile: "settings.json", + JSONPointers: []string{"/apiKeyHelper", "/env/NODE_TLS_REJECT_UNAUTHORIZED"}, + ValueFields: []string{"TokenHelperCommand", "NodeTLSRejectUnauthorized"}, + ClearWhenEmpty: []bool{false, true}, + }}) + return NewTestClientManager(home, nil, cfgs, nil), home +} + +func TestConfigureLLMGateway_TLSSkipVerify_WritesNodeEnv(t *testing.T) { + t.Parallel() + cm, home := newTLSTestManager(t) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + _, err := cm.ConfigureLLMGateway(ClaudeCode, LLMApplyConfig{ + TokenHelperCommand: `"thv" llm token`, + TLSSkipVerify: true, + }) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + require.NoError(t, err) + assert.Contains(t, string(data), "NODE_TLS_REJECT_UNAUTHORIZED") + assert.Contains(t, string(data), `"0"`) +} + +func TestConfigureLLMGateway_TLSSkipVerify_NotSet_DoesNotWriteNodeEnv(t *testing.T) { + t.Parallel() + cm, home := newTLSTestManager(t) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + _, err := cm.ConfigureLLMGateway(ClaudeCode, LLMApplyConfig{ + TokenHelperCommand: `"thv" llm token`, + TLSSkipVerify: false, + }) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + require.NoError(t, err) + assert.NotContains(t, string(data), "NODE_TLS_REJECT_UNAUTHORIZED") +} + +func TestConfigureLLMGateway_TLSSkipVerify_ClearRemovesKey(t *testing.T) { + t.Parallel() + cm, home := newTLSTestManager(t) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + // First run: set tls-skip-verify + _, err := cm.ConfigureLLMGateway(ClaudeCode, LLMApplyConfig{ + TokenHelperCommand: `"thv" llm token`, + TLSSkipVerify: true, + }) + require.NoError(t, err) + + settingsPath := filepath.Join(claudeDir, "settings.json") + data, err := os.ReadFile(settingsPath) + require.NoError(t, err) + require.Contains(t, string(data), "NODE_TLS_REJECT_UNAUTHORIZED", "key must be present after first configure") + + // Second run: clear tls-skip-verify + _, err = cm.ConfigureLLMGateway(ClaudeCode, LLMApplyConfig{ + TokenHelperCommand: `"thv" llm token`, + TLSSkipVerify: false, + }) + require.NoError(t, err) + + data, err = os.ReadFile(settingsPath) + require.NoError(t, err) + assert.NotContains(t, string(data), "NODE_TLS_REJECT_UNAUTHORIZED", "key must be removed when TLSSkipVerify is cleared") +} + // countSubstring counts non-overlapping occurrences of substr in s. func countSubstring(s, substr string) int { count := 0 diff --git a/pkg/client/test_support.go b/pkg/client/test_support.go index abedb97711..30894dcde5 100644 --- a/pkg/client/test_support.go +++ b/pkg/client/test_support.go @@ -7,12 +7,13 @@ package client // use in tests. No platform prefix is applied, so settings files resolve as // homeDir/SettingsDir.../SettingsFile on all platforms. type LLMTestEntry struct { - ClientType ClientApp - Mode string // "direct" or "proxy" - SettingsDir []string // path segments from homeDir to the settings directory - SettingsFile string // settings filename - JSONPointers []string // RFC 6901 JSON Pointer paths to patch - ValueFields []string // value-field names parallel to JSONPointers + ClientType ClientApp + Mode string // "direct" or "proxy" + SettingsDir []string // path segments from homeDir to the settings directory + SettingsFile string // settings filename + JSONPointers []string // RFC 6901 JSON Pointer paths to patch + ValueFields []string // value-field names parallel to JSONPointers + ClearWhenEmpty []bool // ClearWhenEmpty flags parallel to JSONPointers (optional) } // LLMTestIntegrations converts []LLMTestEntry into an internal []clientAppConfig @@ -28,7 +29,11 @@ func LLMTestIntegrations(entries []LLMTestEntry) []clientAppConfig { if j < len(e.ValueFields) { vf = e.ValueFields[j] } - keys[j] = LLMGatewayKeySpec{JSONPointer: ptr, ValueField: vf} + cwe := false + if j < len(e.ClearWhenEmpty) { + cwe = e.ClearWhenEmpty[j] + } + keys[j] = LLMGatewayKeySpec{JSONPointer: ptr, ValueField: vf, ClearWhenEmpty: cwe} } cfgs[i] = clientAppConfig{ ClientType: e.ClientType, diff --git a/pkg/llm/config.go b/pkg/llm/config.go index d8706040e8..a4140a36ca 100644 --- a/pkg/llm/config.go +++ b/pkg/llm/config.go @@ -26,6 +26,7 @@ type OIDCConfig = pkgoidc.ClientConfig // ToolHive's config.yaml. type Config struct { GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` + TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` ConfiguredTools []ToolConfig `yaml:"configured_tools,omitempty" json:"configured_tools,omitempty"` diff --git a/pkg/llm/manage.go b/pkg/llm/manage.go index 2e6338978d..9dd6b03949 100644 --- a/pkg/llm/manage.go +++ b/pkg/llm/manage.go @@ -35,6 +35,9 @@ func (c *Config) SetFields(opts SetOptions) error { if opts.CallbackPort != 0 { c.OIDC.CallbackPort = opts.CallbackPort } + if opts.TLSSkipVerify != nil { + c.TLSSkipVerify = *opts.TLSSkipVerify + } if !c.IsConfigured() { return c.ValidatePartial() @@ -44,14 +47,16 @@ func (c *Config) SetFields(opts SetOptions) error { // SetOptions carries the flag values for the "config set" command. // Zero values are treated as "not provided" and leave the existing config -// field unchanged. +// field unchanged. TLSSkipVerify uses a pointer so that false can be +// distinguished from "not provided" (enabling explicit clear via config set). type SetOptions struct { - GatewayURL string - Issuer string - ClientID string - Audience string - ProxyPort int - CallbackPort int + GatewayURL string + Issuer string + ClientID string + Audience string + ProxyPort int + CallbackPort int + TLSSkipVerify *bool // nil = not provided; &false = explicitly disable } // DeleteCachedTokens removes all cached OIDC tokens stored under the LLM @@ -93,14 +98,17 @@ func (c *Config) Show(w io.Writer) error { } } - writef("Gateway URL: %s\n", c.GatewayURL) - writef("OIDC Issuer: %s\n", c.OIDC.Issuer) - writef("OIDC Client: %s\n", c.OIDC.ClientID) + writef("Gateway URL: %s\n", c.GatewayURL) + writef("OIDC Issuer: %s\n", c.OIDC.Issuer) + writef("OIDC Client: %s\n", c.OIDC.ClientID) if c.OIDC.Audience != "" { - writef("Audience: %s\n", c.OIDC.Audience) + writef("Audience: %s\n", c.OIDC.Audience) + } + writef("Proxy Port: %d\n", c.EffectiveProxyPort()) + writef("Scopes: %v\n", c.OIDC.EffectiveScopes()) + if c.TLSSkipVerify { + writef("TLS Skip Verify: true (WARNING: certificate verification disabled)\n") } - writef("Proxy Port: %d\n", c.EffectiveProxyPort()) - writef("Scopes: %v\n", c.OIDC.EffectiveScopes()) if len(c.ConfiguredTools) > 0 { writef("Configured tools:\n") for _, t := range c.ConfiguredTools { diff --git a/pkg/llm/manage_test.go b/pkg/llm/manage_test.go index 2118f788f0..9235317116 100644 --- a/pkg/llm/manage_test.go +++ b/pkg/llm/manage_test.go @@ -96,6 +96,29 @@ func TestConfig_SetFields(t *testing.T) { }, wantErr: true, }, + { + name: "TLSSkipVerify pointer true sets field", + opts: SetOptions{TLSSkipVerify: boolPtr(true)}, + want: Config{TLSSkipVerify: true}, + }, + { + name: "TLSSkipVerify pointer false clears field", + base: Config{ + GatewayURL: "https://gw.example.com", + TLSSkipVerify: true, + }, + opts: SetOptions{TLSSkipVerify: boolPtr(false)}, + want: Config{GatewayURL: "https://gw.example.com", TLSSkipVerify: false}, + }, + { + name: "nil TLSSkipVerify pointer leaves existing value unchanged", + base: Config{ + GatewayURL: "https://gw.example.com", + TLSSkipVerify: true, + }, + opts: SetOptions{}, + want: Config{GatewayURL: "https://gw.example.com", TLSSkipVerify: true}, + }, } for _, tt := range tests { @@ -128,10 +151,15 @@ func TestConfig_SetFields(t *testing.T) { if cfg.OIDC.CallbackPort != tt.want.OIDC.CallbackPort { t.Errorf("OIDC.CallbackPort = %d, want %d", cfg.OIDC.CallbackPort, tt.want.OIDC.CallbackPort) } + if cfg.TLSSkipVerify != tt.want.TLSSkipVerify { + t.Errorf("TLSSkipVerify = %v, want %v", cfg.TLSSkipVerify, tt.want.TLSSkipVerify) + } }) } } +func boolPtr(b bool) *bool { return &b } + // ── DeleteCachedTokens ─────────────────────────────────────────────────────── func TestDeleteCachedTokens(t *testing.T) { @@ -271,6 +299,24 @@ func TestConfig_Show(t *testing.T) { }, contains: []string{"cursor", "proxy", "/home/user/.cursor/config.json"}, }, + { + name: "TLS skip verify shown with warning when set", + cfg: Config{ + GatewayURL: "https://gw.example.com", + TLSSkipVerify: true, + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + }, + contains: []string{"TLS Skip Verify", "true", "WARNING"}, + }, + { + name: "TLS skip verify not shown when false", + cfg: Config{ + GatewayURL: "https://gw.example.com", + TLSSkipVerify: false, + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + }, + absent: []string{"TLS Skip Verify"}, + }, } for _, tt := range tests { diff --git a/pkg/llm/proxy/proxy.go b/pkg/llm/proxy/proxy.go index 5ef14886d4..d66e6a6fd9 100644 --- a/pkg/llm/proxy/proxy.go +++ b/pkg/llm/proxy/proxy.go @@ -6,6 +6,7 @@ package proxy import ( "context" + "crypto/tls" "errors" "fmt" "io" @@ -26,6 +27,30 @@ type TokenSource interface { Token(ctx context.Context) (string, error) } +// Option configures optional Proxy behaviour. +type Option func(*Proxy) + +// WithTLSSkipVerify disables TLS certificate verification for the upstream +// gateway connection. This is intended for local development only (e.g., +// self-signed certificates). It must NOT be used in production. +func WithTLSSkipVerify(skip bool) Option { + return func(p *Proxy) { + if !skip { + return + } + slog.Warn("LLM proxy: TLS certificate verification disabled for upstream — non-production use only") + // Clone http.DefaultTransport so we preserve all production defaults + // (timeouts, ProxyFromEnvironment, HTTP/2, connection pooling) and only + // toggle InsecureSkipVerify. + base := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert // DefaultTransport is always *http.Transport + if base.TLSClientConfig == nil { + base.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + base.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec // G402: intentional for local dev with self-signed certs + p.transport = base + } +} + // tokenContextKey is the context key used to pass the injected token to the // Rewrite hook without modifying the original incoming request. type tokenContextKey struct{} @@ -60,7 +85,7 @@ type Proxy struct { // returns the correct address before Start is called. Returns an error if // GatewayURL is unparsable, the listen address is not loopback, or the port // is already in use. -func New(cfg *llm.Config, ts TokenSource) (*Proxy, error) { +func New(cfg *llm.Config, ts TokenSource, opts ...Option) (*Proxy, error) { if cfg == nil { return nil, fmt.Errorf("cfg must not be nil") } @@ -92,6 +117,9 @@ func New(cfg *llm.Config, ts TokenSource) (*Proxy, error) { tokenSource: ts, listener: ln, } + for _, o := range opts { + o(p) + } p.rp = &httputil.ReverseProxy{ // Transport delegates to p.transport so tests can swap it after New(). Transport: (*proxyTransport)(p), diff --git a/pkg/llm/proxy/proxy_test.go b/pkg/llm/proxy/proxy_test.go index 1b0db93208..11516f6638 100644 --- a/pkg/llm/proxy/proxy_test.go +++ b/pkg/llm/proxy/proxy_test.go @@ -343,6 +343,51 @@ func TestProxy_PassesThroughSSE(t *testing.T) { assert.Contains(t, string(got), "data: [DONE]") } +func TestWithTLSSkipVerify(t *testing.T) { + t.Parallel() + + // Self-signed upstream — default transport cannot verify this certificate. + gateway := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(gateway.Close) + + t.Run("default transport rejects self-signed cert", func(t *testing.T) { + t.Parallel() + cfg := &llm.Config{ + GatewayURL: gateway.URL, + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + p, err := New(cfg, &stubTokenSource{token: "tok"}) + require.NoError(t, err) + t.Cleanup(func() { _ = p.listener.Close() }) + + req := loopbackRequest("/v1/models") + w := httptest.NewRecorder() + p.handler().ServeHTTP(w, req) + + // Certificate verification failure surfaces as 502 Bad Gateway. + assert.Equal(t, http.StatusBadGateway, w.Code) + }) + + t.Run("WithTLSSkipVerify(true) accepts self-signed cert", func(t *testing.T) { + t.Parallel() + cfg := &llm.Config{ + GatewayURL: gateway.URL, + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + p, err := New(cfg, &stubTokenSource{token: "tok"}, WithTLSSkipVerify(true)) + require.NoError(t, err) + t.Cleanup(func() { _ = p.listener.Close() }) + + req := loopbackRequest("/v1/models") + w := httptest.NewRecorder() + p.handler().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) +} + func TestProxy_PassesThroughErrorResponses(t *testing.T) { t.Parallel() for _, statusCode := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError} { diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index a286ed748b..a2155536c9 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -25,6 +25,7 @@ type ToolApplyConfig struct { GatewayURL string // direct-mode: URL of the upstream LLM gateway ProxyBaseURL string // proxy-mode: URL of the localhost reverse proxy TokenHelperCommand string // direct-mode: shell command that prints a fresh token + TLSSkipVerify bool // when true, instruct the tool to skip TLS verification } // GatewayManager is the subset of client.ClientManager used by Setup and @@ -113,29 +114,14 @@ func Setup( } _, _ = fmt.Fprintln(out, "Login successful.") - var configured []ToolConfig - for _, clientType := range detected { - configPath, err := gm.ConfigureLLMGateway(clientType, ToolApplyConfig{ - GatewayURL: llmCfg.GatewayURL, - ProxyBaseURL: proxyBaseURL, - TokenHelperCommand: tokenHelperCommand, - }) - if err != nil { - _, _ = fmt.Fprintf(errOut, "Warning: failed to configure %s: %v\n", clientType, err) - continue - } - mode := gm.LLMGatewayModeFor(clientType) - configured = append(configured, ToolConfig{ - Tool: clientType, - Mode: mode, - ConfigPath: configPath, - }) - _, _ = fmt.Fprintf(out, "Configured %s (%s mode) → %s\n", clientType, mode, configPath) + configured, err := configureDetectedTools( + out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, tokenHelperCommand, llmCfg.TLSSkipVerify, + ) + if err != nil { + return err } - if len(configured) == 0 { - return fmt.Errorf("failed to configure any detected tools") - } + warnTLSSkipVerify(errOut, llmCfg.TLSSkipVerify, configured) if err := provider.UpdateLLMConfig(func(c *Config) error { // SetFields applies inline opts to the on-disk config (preserving any @@ -288,6 +274,69 @@ func mergeToolConfigs(existing, incoming []ToolConfig) []ToolConfig { return result } +// warnTLSSkipVerify prints mode-accurate warnings when TLS verification is +// disabled. The impact differs by tool mode: +// - direct (Node.js tools like Claude Code, Gemini CLI): NODE_TLS_REJECT_UNAUTHORIZED=0 +// is written to the tool's settings, disabling TLS for ALL of that tool's outbound +// connections — not just the LLM gateway. +// - proxy: only the proxy's upstream connection to the gateway has TLS verification +// disabled; the tool itself is unaffected. +func warnTLSSkipVerify(errOut io.Writer, skip bool, configured []ToolConfig) { + if !skip { + return + } + for _, tc := range configured { + switch tc.Mode { + case "direct": + _, _ = fmt.Fprintf(errOut, + "Warning: %s uses direct mode — NODE_TLS_REJECT_UNAUTHORIZED=0 has been written to its "+ + "settings, disabling TLS certificate verification for ALL of %s's outbound connections "+ + "(LLM provider APIs, MCP registry, etc.), not just the LLM gateway. "+ + "Use only in isolated local environments.\n", tc.Tool, tc.Tool) + case "proxy": + _, _ = fmt.Fprintf(errOut, + "Warning: %s uses proxy mode — TLS certificate verification is disabled for the "+ + "proxy's upstream gateway connection only. Use only in isolated local environments.\n", tc.Tool) + } + } +} + +// configureDetectedTools patches each detected tool's config file and returns +// the list of successfully configured tools. An error is returned only when no +// tool was configured successfully. +func configureDetectedTools( + out, errOut io.Writer, + gm GatewayManager, + detected []string, + gatewayURL, proxyBaseURL, tokenHelperCommand string, + tlsSkipVerify bool, +) ([]ToolConfig, error) { + var configured []ToolConfig + for _, clientType := range detected { + configPath, err := gm.ConfigureLLMGateway(clientType, ToolApplyConfig{ + GatewayURL: gatewayURL, + ProxyBaseURL: proxyBaseURL, + TokenHelperCommand: tokenHelperCommand, + TLSSkipVerify: tlsSkipVerify, + }) + if err != nil { + _, _ = fmt.Fprintf(errOut, "Warning: failed to configure %s: %v\n", clientType, err) + continue + } + mode := gm.LLMGatewayModeFor(clientType) + configured = append(configured, ToolConfig{ + Tool: clientType, + Mode: mode, + ConfigPath: configPath, + }) + _, _ = fmt.Fprintf(out, "Configured %s (%s mode) → %s\n", clientType, mode, configPath) + } + if len(configured) == 0 { + return nil, fmt.Errorf("failed to configure any detected tools") + } + return configured, nil +} + // hasProxyMode reports whether any of the given tool configs uses proxy mode. func hasProxyMode(cfgs []ToolConfig) bool { for _, t := range cfgs {