Skip to content

Use an absolute thv path in the Claude Desktop helper shim - #6354

Open
jerm-dro wants to merge 3 commits into
mainfrom
jerm-dro/01M08M6CJJYZ3GJPD7ZC3XQTV1
Open

Use an absolute thv path in the Claude Desktop helper shim#6354
jerm-dro wants to merge 3 commits into
mainfrom
jerm-dro/01M08M6CJJYZ3GJPD7ZC3XQTV1

Conversation

@jerm-dro

@jerm-dro jerm-dro commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Claude Desktop's LLM gateway auth is broken in every case on macOS. Its credential-helper shim runs exec thv llm token, but Claude Desktop is only ever GUI-launched, and macOS GUI apps inherit launchd's environment rather than the user's shell PATH. thv installs to ~/.toolhive/bin/thv, which is not in launchd's default /usr/bin:/bin:/usr/sbin:/sbin:

$ env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin /bin/sh -c "thv llm token"
/bin/sh: thv: command not found

thv llm setup reports success; the failure only surfaces later, as an auth error, when Desktop invokes the helper. This regressed in #6326, which replaced the interpolated os.Executable() path with a bare thv to fix thv llm setup on Windows.

The absolute path was already plumbed and simply unused. pkg/llm/setup.go sets TokenHelperPath on the ApplyConfig passed to every client, but configureCredentialHelper read only TokenHelperCommand.

What changed:

  • pkg/client: the Claude Desktop shim now consumes cfg.TokenHelperPath and single-quotes it into the exec lines. Adds an unexported quoteForPOSIXShell next to its only caller.
  • Deletes isSafeTokenHelperCommand and its metacharacter blocklist (−1 function, net −54 lines in that file). The blocklist barred \, ' and ", so it would in fact have rejected a legitimately quoted path.
  • Fixes two stale buildTokenHelperCommand references left by Use a bare thv command as the LLM token helper #6326.

Deliberately unchanged: direct-mode clients (Claude Code) keep the bare thv llm token. They are terminal-oriented, and the bare form re-resolves on every invocation, so upgrading, reinstalling, or relocating thv keeps working without re-running thv llm setup. That property is worth more there than closing the Dock-launch gap. Claude Desktop is the one client that cannot make that trade, since it is only ever GUI-launched.

Because no direct-mode client interpolates a path any more, there is no platform branch: nothing needs different quoting for /bin/sh and cmd.exe. The shim is POSIX-only by construction — configureCredentialHelper hard-errors on Windows — so single-quoting is the only scheme required.

Why dropping validation is safe: on POSIX, single-quoting is a total transform. Inside a single-quoted run no byte is special to the shell — not $, backtick, backslash, or even a newline — so there is no input to reject and no metacharacter validation is needed to keep the 0700 script injection-free.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Manual, end-to-end with the real binary. Installed thv at /tmp/e2e/bin dir's/thv — a path containing both a space and an apostrophe — and ran real thv llm setup against a sandboxed HOME.

Claude Desktop's shim, executed under a launchd-like PATH that excludes thv:

exec '/tmp/e2e/bin dir'\''s/thv' llm token --skip-browser

with this fix:  Error: failed to get secrets provider…   ← thv found and executed
before:         /bin/sh: thv: command not found          ← the regression

The remaining secrets error is an unconfigured sandbox, far past the point of failure. Also confirmed Claude Code's apiKeyHelper is still written as the bare thv llm token, unchanged by this PR.

New automated coverage:

  • TestQuoteForPOSIXShell_SurvivesRealShell round-trips 12 hostile strings through /bin/sh and asserts byte-identical output, plus a control proving an unquoted string does not survive (so it cannot pass trivially).
  • TestWriteCredentialHelperShim_ExecutesWithHostilePath executes the generated shim with a path containing ', ", $(id), backticks, ; and a newline, and asserts the received argv — this is the evidence that justifies deleting the validator.
  • TestWriteCredentialHelperShim_UsesAbsolutePath pins that the interactive branch does not pass --skip-browser.

task docs produces no diff (no CLI surface changed).

Changes

File Change
pkg/client/llm_gateway_credential_helper.go Shim uses TokenHelperPath; add quoteForPOSIXShell; delete isSafeTokenHelperCommand; fix stale comments
pkg/llm/setup.go Document why direct-mode deliberately keeps the bare command
pkg/client/llm_gateway_credential_helper_test.go Escaper round-trip + shim-execution tests; replace blocklist test

Does this introduce a user-facing change?

Yes. thv llm setup now writes an absolute path to thv in Claude Desktop's credential-helper shim, fixing LLM gateway auth for Claude Desktop on macOS, where it was previously broken in every case. Affected users should re-run thv llm setup. No change for Claude Code, Codex, Gemini CLI, or any Windows client.

Special notes for reviewers

  1. Deleting isSafeTokenHelperCommand removes a defense-in-depth layer. I believe it is correct — the escaper makes every byte inert, so the blocklist protected nothing an attacker controlling TokenHelperPath couldn't reach by controlling the whole ApplyConfig — but it is a net reduction in belt-and-braces and deserves a deliberate look rather than being buried in the diff. TestWriteCredentialHelperShim_ExecutesWithHostilePath is the replacement guarantee, and it is strictly stronger: it asserts behavior under a real shell instead of the shape of a string.

  2. The shim's path is now a setup-time snapshot. If a user moves or deletes the binary, the Desktop helper breaks until thv llm setup is re-run. Verified favorable: os.Executable() does not resolve symlinks on darwin, so Homebrew's /opt/homebrew/bin/thv symlink is recorded rather than the versioned Cellar target, and brew upgrade keeps working. Failure is loud and local, versus today's silent-and-unconditional break. This staleness risk is precisely why direct-mode clients keep the bare, re-resolving command.

  3. quoteForPOSIXShell is unexported in pkg/client, not shared. With one consumer, exported surface in pkg/llmgateway wasn't warranted. Note pkg/tui/inspector.go has its own equivalent; unifying them is unrelated scope.

Generated with Claude Code

The bare "thv llm token" token helper never runs for GUI-launched
clients. macOS GUI apps inherit launchd's environment, not the user's
shell PATH, and thv installs to ~/.toolhive/bin, which launchd's
default PATH does not include. Claude Desktop is broken
unconditionally since it is only ever GUI-launched; its setup still
reports success and the failure surfaces later as an auth error.
Claude Code is broken whenever it is started from the Dock or
Spotlight. The same PATH dependency also lets any writable directory
earlier on PATH shadow thv and return an attacker-chosen token.

Single-quote the absolute path from os.Executable() on POSIX. Quoting
is what the previous attempt could not do portably, but the two
consumers here only ever run through /bin/sh, where single-quoting is
a total transform: no byte inside a single-quoted run is special, so
no path needs rejecting and the shim's metacharacter blocklist is
deleted rather than widened. Claude Desktop's shim switches to the
TokenHelperPath already plumbed to it and previously ignored.

Windows keeps the bare command. cmd.exe has no equivalent quoting for
backslash-bearing paths, and leaving that branch byte-identical to
what ships today means it cannot regress; Dock-equivalent launches
there still need thv on the GUI PATH.
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Aug 17, 2026
@jerm-dro

Copy link
Copy Markdown
Collaborator Author

Follow-up for the deliberately-deferred Windows half of this: #6355 (token helper still resolves via PATH on Windows — GUI-launch gap plus PATH-shadowing vector, unfixed here because that branch is the one no CI runner covers).

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.95%. Comparing base (55feedf) to head (d6011a5).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6354      +/-   ##
==========================================
- Coverage   72.97%   72.95%   -0.02%     
==========================================
  Files         742      742              
  Lines       78398    78392       -6     
==========================================
- Hits        57214    57194      -20     
- Misses      17194    17210      +16     
+ Partials     3990     3988       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Scope the absolute-path fix to Claude Desktop, which is the only
client that cannot use a PATH-resolved command: it is exclusively
GUI-launched, so it inherits launchd's environment and never finds
thv on PATH.

Claude Code keeps the bare "thv llm token". It is terminal-oriented,
and the bare form re-resolves on every invocation, so upgrading or
relocating thv keeps working without re-running "thv llm setup".
This also drops the platform branch: with no direct-mode client
interpolating a path, there is nothing that needs different quoting
in /bin/sh and cmd.exe.

Move the shell escaper to pkg/client alongside its only remaining
caller, unexported, rather than leaving new exported surface in
pkg/llmgateway for a single consumer.
@jerm-dro jerm-dro changed the title Use an absolute thv path in POSIX token helpers Use an absolute thv path in the Claude Desktop helper shim Aug 17, 2026
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/M Medium PR: 300-599 lines changed labels Aug 17, 2026
@jerm-dro

Copy link
Copy Markdown
Collaborator Author

Scope corrected in 7d7dc7d after maintainer feedback: direct-mode clients (Claude Code) keep the bare thv llm token on all platforms — that is the intended design, not a gap. The bare command re-resolves each invocation, so upgrading or relocating thv keeps working without re-running thv llm setup.

The absolute path is now scoped to Claude Desktop alone, the one client that cannot use a PATH-resolved command since it is exclusively GUI-launched. Consequences:

  • No platform branch. With no direct-mode client interpolating a path, nothing needs different quoting for /bin/sh and cmd.exe. tokenHelperShellCommandFor is gone; tokenHelperShellCommand is a constant again.
  • Escaper moved to pkg/client, unexported. One consumer no longer justifies exported surface in pkg/llmgateway.
  • Diff shrank from +249/−82 to +108/−162 — a net deletion.

Title and description updated to match. #6355 closed as not planned; its premise was mine, and it was wrong.

@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Aug 17, 2026
@jerm-dro
jerm-dro requested a lite review from Copilot August 17, 2026 21:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Claude Desktop’s LLM gateway credential-helper shim on macOS by avoiding reliance on PATH (GUI-launched apps inherit launchd’s minimal environment). It updates the shim generator to embed an absolute thv path with POSIX-safe quoting, while keeping direct-mode clients on the bare thv llm token command to preserve relocatability.

Changes:

  • Claude Desktop shim now uses ApplyConfig.TokenHelperPath and POSIX single-quoting to safely exec thv with arguments.
  • Removes the previous token-helper command validator in favor of a total quoting transform and stronger execution-based tests.
  • Updates documentation/comments around why direct-mode keeps the bare command.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
pkg/client/llm_gateway_credential_helper.go Generate Claude Desktop shim using absolute TokenHelperPath with POSIX quoting; removes old validation logic.
pkg/llm/setup.go Clarifies rationale/tradeoffs for keeping direct-mode helper as bare thv resolved via PATH.
pkg/client/llm_gateway_credential_helper_test.go Adds quoting and real-shell execution tests to validate shim safety and behavior.
Suppressed comments (1)

pkg/client/llm_gateway_credential_helper_test.go:339

  • Same loop-variable capture issue here: in is the for range variable and the subtests are parallel, so each closure should capture its own copy (in := in) to avoid flakiness/incorrect assertions.
	for _, in := range inputs {
		t.Run(in, func(t *testing.T) {
			t.Parallel()
			// printf %s re-emits the argument verbatim, so any shell
			// interpretation of the quoted form shows up as a mismatch.
			script := "printf %s " + quoteForPOSIXShell(in)
			out, err := exec.Command("/bin/sh", "-c", script).CombinedOutput() // #nosec G204 -- test-controlled input
			require.NoError(t, err, "sh failed: %s", out)
			assert.Equal(t, in, string(out), "quoted string must survive /bin/sh verbatim")

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 65 to 68
// 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.
// tokenHelperShellCommandFor in pkg/llm). Windows support is a follow-up.
return "", fmt.Errorf("claude-desktop LLM gateway setup is not supported on Windows yet")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and thanks — fixed in d6011a5. Leftover from renaming this to tokenHelperShellCommandFor for a platform branch, then dropping the branch after maintainer feedback; the reference reverted everywhere except here. Now reads tokenHelperShellCommand, which is the symbol that exists.

Comment on lines +256 to 262
func (cm *ClientManager) writeCredentialHelperShim(tokenHelperPath string) (string, error) {
if tokenHelperPath == "" {
// A caller bug (os.Executable() failed upstream), not user input: fail
// closed rather than emitting a shim that cannot locate thv.
return "", fmt.Errorf("no token-helper path available for credential helper shim")
}
shimPath := cm.credentialHelperShimPath()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — adopted in d6011a5. The guard is now filepath.IsAbs rather than a non-empty check:

if !filepath.IsAbs(tokenHelperPath) {
    return "", fmt.Errorf(
        "credential helper shim requires an absolute token-helper path, got %q", tokenHelperPath)
}

Your framing is exactly right: defeating PATH resolution is the whole point of the shim, so accepting a relative path would silently reintroduce the ambiguity — resolved against whatever working directory Claude Desktop happens to have, which is worse than the bare-thv case since it is not even predictable.

Two notes:

  • filepath.IsAbs("") is false, so this subsumes the previous empty-string check rather than adding a second guard.
  • Verified it cannot break real usage: os.Executable() is documented to return an absolute path unless it errors. Confirmed empirically by invoking thv through a relative path (./bin/thv llm setup) — setup still succeeds, so the recorded path is absolute regardless of how thv was launched.

TestWriteCredentialHelperShim_RequiresAbsolutePath now covers "", thv, ./thv and ../bin/thv, and additionally asserts no shim file is left behind when the path is rejected.

Comment on lines +299 to +304
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, quoteForPOSIXShell(tc.in))
})
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Respectfully declining this one — the loop-capture bug it describes was fixed in the language itself.

Go 1.22 changed for loops to create a new variable per iteration (spec, wiki), so tc := tc has been a no-op since then. This module is go 1.26 (see go.mod), so the per-iteration semantics apply.

Verified rather than assumed — a table of 3 cases with parallel subtests under go 1.26:

distinct values observed: 3
--- PASS: TestCapture/a
--- PASS: TestCapture/c
--- PASS: TestCapture/b

All three observe their own tc, in non-deterministic completion order. Under the pre-1.22 semantics this would have reported 1 distinct value.

Worth noting the file still contains one tc := tc at line 270, in a test that predates this PR. That is now dead code for the same reason — but removing it is unrelated to this change, so I have left it rather than widening the diff.

Defeating PATH resolution is the entire point of the shim, but the
writer accepted any non-empty string. A relative path would have
silently reintroduced PATH-style ambiguity, resolved against whatever
working directory Claude Desktop happens to run with.

os.Executable() is documented to return an absolute path unless it
errors, so a non-absolute value is a caller bug: reject it instead of
writing a shim that cannot reliably locate thv. filepath.IsAbs also
rejects the empty string, so this subsumes the previous empty check.

Also fix a comment naming a symbol that no longer exists.
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Aug 18, 2026
@jerm-dro

Copy link
Copy Markdown
Collaborator Author

All three Copilot comments addressed in d6011a5 — two adopted, one declined with evidence (replies inline):

  1. Stale tokenHelperShellCommandFor reference — valid, fixed. Leftover from a rename I reverted after maintainer feedback.
  2. Shim should require an absolute path — valid and worth having; adopted. Now guarded with filepath.IsAbs, which also subsumes the previous empty-string check. Verified it cannot break real usage: os.Executable() returns an absolute path even when thv is launched via a relative path (checked by running ./bin/thv llm setup).
  3. Loop-variable capture in parallel subtests — declined. Go 1.22 made for loops create a new variable per iteration, and this module is go 1.26, so tc := tc is a no-op. Verified with a 3-case parallel table under go 1.26: 3 distinct values observed.

Note on the red check: GitHub Actions Static Analysis fails, but it is pre-existing and unrelated to this PR. This branch touches no workflow files (git diff origin/main --name-only | grep ^.github/ → 0). The finding is a zizmor ref-version-mismatch in .github/workflows/spellcheck.yml, where a Renovate-pinned codespell action hash now resolves to tag v2.1 while the comment says v2. The same check fails on main at 55feedf75, the commit this branch is based on. Fixing it here would mix unrelated scope, so I have left it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants