Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions cmd/thv/app/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
})
Expand All @@ -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
}
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
}
Expand Down
17 changes: 14 additions & 3 deletions pkg/client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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},
},
},
{
Expand Down Expand Up @@ -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},
},
},
{
Expand Down
99 changes: 78 additions & 21 deletions pkg/client/llm_gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand All @@ -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.
Expand Down Expand Up @@ -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":
Expand All @@ -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
}
Expand Down
85 changes: 85 additions & 0 deletions pkg/client/llm_gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading