@@ -6,9 +6,11 @@ package client
66import (
77 "encoding/json"
88 "fmt"
9+ "log/slog"
910 "os"
1011 "path/filepath"
1112 "runtime"
13+ "strings"
1214
1315 "github.com/google/uuid"
1416
@@ -23,6 +25,12 @@ import (
2325// writer here rather than going through the LLMGatewayKeys machinery.
2426
2527const (
28+ // credentialHelperMode is the LLMGatewayMode value for clients configured via
29+ // the document + selector credential-helper model (Claude Desktop). It is the
30+ // single source of truth dispatched on in both pkg/client and pkg/llm.
31+ // #nosec G101 -- this is an LLM gateway mode identifier, not a credential.
32+ credentialHelperMode = "credential-helper"
33+
2634 // claudeDesktopManagedEntryName is the stable display name of the config
2735 // entry ToolHive owns in _meta.json. Reusing a fixed name (rather than a
2836 // fresh UUID per run) keeps setup idempotent — repeated "thv llm setup"
@@ -72,26 +80,39 @@ func (cm *ClientManager) configureCredentialHelper(appCfg *clientAppConfig, cfg
7280 return "" , fmt .Errorf ("creating %s: %w" , dir , err )
7381 }
7482
75- // Track whether the shim already existed so failure cleanup does not delete a
76- // shim an earlier successful setup still depends on (setup is idempotent).
77- shimExisted := fileExistsAt (cm .credentialHelperShimPath ())
78- shimPath , err := cm .writeCredentialHelperShim (cfg .TokenHelperCommand )
79- if err != nil {
80- return "" , err
81- }
82-
83- baseURL := cfg .AnthropicBaseURL
84- if baseURL == "" {
85- baseURL = cfg .GatewayURL
86- }
83+ // Reuse the shared AnthropicBaseURL resolution (falls back to GatewayURL) so
84+ // this stays in sync with direct-mode clients if the rule ever changes.
85+ baseURL , _ := resolveApplyConfigField ("AnthropicBaseURL" , cfg )
8786
87+ // Write the shim, config document, and _meta.json selector all inside the
88+ // lock so concurrent setup/teardown runs cannot interleave — e.g. one run's
89+ // failure-cleanup deleting a shim another run's committed config depends on.
90+ // _meta.json is written last, so a mid-write failure never leaves Claude
91+ // Desktop pointing at our config; best-effort cleanup then removes the
92+ // unreferenced files it created.
8893 var configPath string
89- err = fileutils .WithFileLock (metaPath , func () error {
90- meta , err := readClaudeDesktopMeta (metaPath )
94+ err := fileutils .WithFileLock (metaPath , func () error {
95+ // Track whether the shim already existed so failure cleanup does not
96+ // delete a shim an earlier successful setup still depends on.
97+ shimExisted := fileExistsAt (cm .credentialHelperShimPath ())
98+ shimPath , err := cm .writeCredentialHelperShim (cfg .TokenHelperCommand )
9199 if err != nil {
92100 return err
93101 }
102+ cleanup := func (cp string ) {
103+ if ! shimExisted {
104+ _ = os .Remove (shimPath )
105+ }
106+ if cp != "" {
107+ _ = os .Remove (cp )
108+ }
109+ }
94110
111+ meta , err := readClaudeDesktopMeta (metaPath )
112+ if err != nil {
113+ cleanup ("" )
114+ return err
115+ }
95116 id := metaEntryID (meta , claudeDesktopManagedEntryName )
96117 if id == "" {
97118 id = uuid .NewString ()
@@ -114,26 +135,22 @@ func (cm *ClientManager) configureCredentialHelper(appCfg *clientAppConfig, cfg
114135 }
115136 docBytes , err := json .MarshalIndent (doc , "" , " " )
116137 if err != nil {
138+ cleanup ("" )
117139 return fmt .Errorf ("encoding Claude Desktop config: %w" , err )
118140 }
119- configPath = filepath .Join (dir , id + ".json" )
120- if err := fileutils .AtomicWriteFile (configPath , docBytes , 0o600 ); err != nil {
121- return fmt .Errorf ("writing %s: %w" , configPath , err )
141+ cp := filepath .Join (dir , id + ".json" )
142+ if err := fileutils .AtomicWriteFile (cp , docBytes , 0o600 ); err != nil {
143+ cleanup (cp )
144+ return fmt .Errorf ("writing %s: %w" , cp , err )
122145 }
123-
124- return writeClaudeDesktopMeta (metaPath , meta )
146+ if err := writeClaudeDesktopMeta (metaPath , meta ); err != nil {
147+ cleanup (cp )
148+ return err
149+ }
150+ configPath = cp
151+ return nil
125152 })
126153 if err != nil {
127- // Best-effort cleanup so a failed setup does not leave partial state.
128- // _meta.json is written last inside the lock, so on failure Claude Desktop
129- // is never left pointing at our config — only unreferenced files remain.
130- // Only remove the shim if we created it this call.
131- if ! shimExisted {
132- _ = os .Remove (shimPath )
133- }
134- if configPath != "" {
135- _ = os .Remove (configPath )
136- }
137154 return "" , err
138155 }
139156 return configPath , nil
@@ -155,47 +172,49 @@ func (cm *ClientManager) revertCredentialHelper(appCfg *clientAppConfig, configP
155172 if configPath == "" {
156173 return nil
157174 }
175+ dir := filepath .Dir (configPath )
176+ if ! fileExistsAt (dir ) {
177+ // Config directory already gone — nothing to revert.
178+ return nil
179+ }
158180
159181 id := metaIDFromConfigPath (configPath )
160- metaPath := filepath .Join (filepath .Dir (configPath ), appCfg .LLMSettingsFile )
182+ metaPath := filepath .Join (dir , appCfg .LLMSettingsFile )
183+ shimPath := cm .credentialHelperShimPath ()
161184
162- // Step 1: de-reference our config in _meta.json and delete the config
163- // document, under the file lock. After this, Claude Desktop no longer points
164- // at our config regardless of what happens to the shim.
165- if _ , err := os .Stat (metaPath ); err == nil {
166- if lockErr := fileutils .WithFileLock (metaPath , func () error {
185+ // All steps run under the metaPath lock so a concurrent setup/teardown cannot
186+ // interleave. Ordering: de-reference _meta.json, delete the config document,
187+ // then remove the shim last — so a mid-revert failure never leaves Claude
188+ // Desktop pointing at a config whose helper is gone.
189+ return fileutils .WithFileLock (metaPath , func () error {
190+ if fileExistsAt (metaPath ) {
167191 meta , err := readClaudeDesktopMeta (metaPath )
168192 if err != nil {
169193 return err
170194 }
171195 meta ["entries" ] = removeMetaEntry (metaEntries (meta ), id )
172196 // Only clear appliedId if it still points at our config; leave a
173197 // user's own active config selection alone.
174- if applied , _ := meta ["appliedId" ].(string ); applied == id {
198+ applied , ok := meta ["appliedId" ].(string )
199+ if ! ok && meta ["appliedId" ] != nil {
200+ slog .Warn ("Claude Desktop _meta.json has a non-string appliedId; leaving it unchanged" ,
201+ "path" , metaPath )
202+ }
203+ if applied == id {
175204 meta ["appliedId" ] = ""
176205 }
177206 if err := writeClaudeDesktopMeta (metaPath , meta ); err != nil {
178207 return err
179208 }
180- return removeIfExists (configPath )
181- }); lockErr != nil {
182- return lockErr
183209 }
184- } else if os .IsNotExist (err ) {
185- // Selector already gone; still make sure the config document is removed.
186- if rmErr := removeIfExists (configPath ); rmErr != nil {
187- return rmErr
210+ if err := removeIfExists (configPath ); err != nil {
211+ return err
188212 }
189- } else {
190- return fmt .Errorf ("checking %s: %w" , metaPath , err )
191- }
192-
193- // Step 2: remove the shim, now that nothing references it.
194- shimPath := cm .credentialHelperShimPath ()
195- if err := os .Remove (shimPath ); err != nil && ! os .IsNotExist (err ) {
196- return fmt .Errorf ("removing credential helper shim %s: %w" , shimPath , err )
197- }
198- return nil
213+ if err := os .Remove (shimPath ); err != nil && ! os .IsNotExist (err ) {
214+ return fmt .Errorf ("removing credential helper shim %s: %w" , shimPath , err )
215+ }
216+ return nil
217+ })
199218}
200219
201220// credentialHelperShimPath is the fixed location of the generated shim.
@@ -243,11 +262,21 @@ func managedProfilePresent(domain string) bool {
243262 if domain == "" || runtime .GOOS != "darwin" {
244263 return false
245264 }
246- if _ , err := os .Stat (filepath .Join ("/Library/Managed Preferences" , domain )); err == nil {
265+ return managedProfileExistsUnder (managedPreferencesRoot , domain )
266+ }
267+
268+ // managedPreferencesRoot is the macOS managed-preferences directory. A package
269+ // variable (not a const) so tests can point it at a temp directory.
270+ var managedPreferencesRoot = "/Library/Managed Preferences"
271+
272+ // managedProfileExistsUnder reports whether a managed-preferences plist for
273+ // domain exists directly under root or under a per-user subdirectory
274+ // (root/<user>/domain). Platform-independent so it is unit-testable.
275+ func managedProfileExistsUnder (root , domain string ) bool {
276+ if _ , err := os .Stat (filepath .Join (root , domain )); err == nil {
247277 return true
248278 }
249- // Per-user managed prefs live under /Library/Managed Preferences/<user>/.
250- matches , _ := filepath .Glob (filepath .Join ("/Library/Managed Preferences" , "*" , domain ))
279+ matches , _ := filepath .Glob (filepath .Join (root , "*" , domain ))
251280 return len (matches ) > 0
252281}
253282
@@ -295,6 +324,10 @@ func metaEntries(meta map[string]any) []any {
295324}
296325
297326// metaEntryID returns the id of the entry with the given name, or "" if absent.
327+ // _meta.json lives in a directory Claude Desktop and users also write to, so the
328+ // stored id is untrusted: an unsafe value is treated as absent so the caller
329+ // mints a fresh, safe UUID rather than joining a path-traversing id into the
330+ // configLibrary path. See isSafeConfigID.
298331func metaEntryID (meta map [string ]any , name string ) string {
299332 for _ , e := range metaEntries (meta ) {
300333 entry , ok := e .(map [string ]any )
@@ -303,12 +336,22 @@ func metaEntryID(meta map[string]any, name string) string {
303336 }
304337 if n , _ := entry ["name" ].(string ); n == name {
305338 id , _ := entry ["id" ].(string )
339+ if ! isSafeConfigID (id ) {
340+ return ""
341+ }
306342 return id
307343 }
308344 }
309345 return ""
310346}
311347
348+ // isSafeConfigID reports whether id is a bare filename safe to join into the
349+ // configLibrary path — non-empty, no path separators, and no "..". Guards
350+ // against a corrupted or hand-edited _meta.json entry escaping configLibrary.
351+ func isSafeConfigID (id string ) bool {
352+ return id != "" && filepath .Base (id ) == id && ! strings .Contains (id , ".." )
353+ }
354+
312355// removeMetaEntry returns entries with the entry matching id removed.
313356func removeMetaEntry (entries []any , id string ) []any {
314357 out := make ([]any , 0 , len (entries ))
0 commit comments