Skip to content

Commit b15418b

Browse files
JAORMXclaudedmjb
authored
Add build auth file injection for protocol builds (#2909)
* Add build auth file injection for protocol builds Add support for injecting authentication files (npmrc, netrc, yarnrc) into container builds for protocol schemes (npx://, uvx://, go://). This solves the problem that NPM registry-scoped authentication cannot be configured via environment variables because the format (//registry.example.com/:_authToken=TOKEN) contains characters that are invalid as environment variable names. Key features: - New CLI commands: set-build-auth-file, get-build-auth-file, unset-build-auth-file - Files are injected into the builder stage only and NOT included in the final container image for security - Supports npmrc (npm/npx), netrc (pip/Go), and yarnrc (Yarn) - Credentials are hidden by default in CLI output (use --show-content to display) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add CLI documentation for build auth file commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add --stdin flag to read auth file content from stdin This avoids exposing secrets in shell history and process listings. Examples: cat ~/.npmrc | thv config set-build-auth-file npmrc --stdin thv config set-build-auth-file npmrc --stdin < ~/.npmrc 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Store build auth file content in encrypted secrets Refactor build auth file storage to use ToolHive's encrypted secrets provider instead of storing credentials in plain text config files. - Config now stores only markers (e.g., "secret:BUILD_AUTH_FILE_npmrc") - Actual content stored in keyring/encrypted secrets provider - CLI commands updated to use secrets manager for storage/retrieval - protocol.go resolves secrets at build time - Updated interface methods and all provider implementations This ensures sensitive credentials like NPM tokens are never stored in plain text on disk. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Clean up auth files in local path builds to prevent leaking to final image When building from a local path (e.g., go://./myapp), the build context is copied to /build/ which may include auth files like .netrc, .npmrc, or .yarnrc. The final stage then copies /build/ to /app/, which would leak these credentials to the final container image. Add cleanup steps after the build completes (so auth was used during package fetching) but before the final stage copies from /build/: - go.tmpl: RUN rm -f /build/.netrc /build/.npmrc /build/.yarnrc /root/.netrc - npx.tmpl: RUN rm -f /build/.netrc /build/.npmrc /build/.yarnrc /root/.npmrc - uvx.tmpl: RUN rm -f /build/.netrc /build/.npmrc /build/.yarnrc /root/.netrc This only affects local path builds (IsLocalPath=true). Remote package builds (e.g., npx://@org/package) are unaffected since they only copy specific files like node_modules or package.json. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Don Browne <dmjb@users.noreply.github.com>
1 parent ff399f7 commit b15418b

File tree

15 files changed

+1203
-0
lines changed

15 files changed

+1203
-0
lines changed
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
package app
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os"
9+
"sort"
10+
"strings"
11+
12+
"github.com/spf13/cobra"
13+
14+
"github.com/stacklok/toolhive/pkg/config"
15+
)
16+
17+
var (
18+
unsetBuildAuthFileAll bool
19+
showAuthFileContent bool
20+
authFileFromStdin bool
21+
)
22+
23+
var setBuildAuthFileCmd = &cobra.Command{
24+
Use: "set-build-auth-file <name> [content]",
25+
Short: "Set an auth file for protocol builds",
26+
Long: `Set authentication file content that will be injected into the container
27+
during protocol builds (npx://, uvx://, go://). This is useful for authenticating
28+
to private package registries.
29+
30+
Supported file types:
31+
npmrc - NPM configuration (~/.npmrc) for npm/npx registries
32+
netrc - Netrc file (~/.netrc) for pip, Go, and other tools
33+
yarnrc - Yarn configuration (~/.yarnrc)
34+
35+
The file content is injected into the build stage only and is NOT included
36+
in the final container image.
37+
38+
Examples:
39+
# Set npmrc for private npm registry
40+
thv config set-build-auth-file npmrc '//npm.corp.example.com/:_authToken=TOKEN'
41+
42+
# Set netrc for pip/Go authentication
43+
thv config set-build-auth-file netrc 'machine github.com login git password TOKEN'
44+
45+
# Read content from stdin (avoids exposing secrets in shell history)
46+
cat ~/.npmrc | thv config set-build-auth-file npmrc --stdin
47+
thv config set-build-auth-file npmrc --stdin < ~/.npmrc
48+
49+
Note: For multi-line content, use quotes, heredoc syntax, or --stdin.`,
50+
Args: cobra.RangeArgs(1, 2),
51+
RunE: setBuildAuthFileCmdFunc,
52+
}
53+
54+
var getBuildAuthFileCmd = &cobra.Command{
55+
Use: "get-build-auth-file [name]",
56+
Short: "Get build auth file configuration",
57+
Long: `Display configured build auth files.
58+
If a name is provided, shows only that specific file.
59+
If no name is provided, shows all configured files.
60+
61+
By default, file contents are hidden to prevent credential exposure.
62+
Use --show-content to display the actual content.
63+
64+
Examples:
65+
thv config get-build-auth-file # Show all files (content hidden)
66+
thv config get-build-auth-file npmrc # Show specific file (content hidden)
67+
thv config get-build-auth-file npmrc --show-content # Show with content`,
68+
Args: cobra.MaximumNArgs(1),
69+
RunE: getBuildAuthFileCmdFunc,
70+
}
71+
72+
var unsetBuildAuthFileCmd = &cobra.Command{
73+
Use: "unset-build-auth-file [name]",
74+
Short: "Remove build auth file(s)",
75+
Long: `Remove a specific build auth file or all files.
76+
77+
Examples:
78+
thv config unset-build-auth-file npmrc # Remove specific file
79+
thv config unset-build-auth-file --all # Remove all files`,
80+
Args: cobra.MaximumNArgs(1),
81+
RunE: unsetBuildAuthFileCmdFunc,
82+
}
83+
84+
func init() {
85+
configCmd.AddCommand(setBuildAuthFileCmd)
86+
configCmd.AddCommand(getBuildAuthFileCmd)
87+
configCmd.AddCommand(unsetBuildAuthFileCmd)
88+
89+
unsetBuildAuthFileCmd.Flags().BoolVar(
90+
&unsetBuildAuthFileAll,
91+
"all",
92+
false,
93+
"Remove all build auth files",
94+
)
95+
96+
getBuildAuthFileCmd.Flags().BoolVar(
97+
&showAuthFileContent,
98+
"show-content",
99+
false,
100+
"Show the actual file content (contains credentials)",
101+
)
102+
103+
setBuildAuthFileCmd.Flags().BoolVar(
104+
&authFileFromStdin,
105+
"stdin",
106+
false,
107+
"Read file content from stdin instead of command line argument",
108+
)
109+
}
110+
111+
func setBuildAuthFileCmdFunc(_ *cobra.Command, args []string) error {
112+
name := args[0]
113+
114+
// Validate the file name first
115+
if err := config.ValidateBuildAuthFileName(name); err != nil {
116+
return err
117+
}
118+
119+
var content string
120+
if authFileFromStdin {
121+
// Read from stdin
122+
data, err := readFromStdin()
123+
if err != nil {
124+
return fmt.Errorf("failed to read from stdin: %w", err)
125+
}
126+
content = data
127+
} else {
128+
// Read from command line argument
129+
if len(args) < 2 {
130+
return fmt.Errorf("content argument required (or use --stdin to read from stdin)")
131+
}
132+
content = args[1]
133+
}
134+
135+
// Get the secrets manager to store the content securely
136+
manager, err := getSecretsManager()
137+
if err != nil {
138+
return fmt.Errorf("failed to get secrets manager: %w (run 'thv secret setup' first)", err)
139+
}
140+
141+
// Store the content in the secrets provider
142+
secretName := config.BuildAuthFileSecretName(name)
143+
ctx := context.Background()
144+
if err := manager.SetSecret(ctx, secretName, content); err != nil {
145+
return fmt.Errorf("failed to store auth file in secrets: %w", err)
146+
}
147+
148+
// Mark the auth file as configured in the config (only a marker, no content)
149+
provider := config.NewDefaultProvider()
150+
if err := provider.MarkBuildAuthFileConfigured(name); err != nil {
151+
// Try to clean up the secret if marking fails
152+
_ = manager.DeleteSecret(ctx, secretName)
153+
return fmt.Errorf("failed to mark build auth file as configured: %w", err)
154+
}
155+
156+
fmt.Printf("Successfully set build auth file: %s (stored securely in secrets)\n", name)
157+
return nil
158+
}
159+
160+
// readFromStdin reads all content from stdin.
161+
func readFromStdin() (string, error) {
162+
// Check if stdin has data (is not a terminal)
163+
stat, err := os.Stdin.Stat()
164+
if err != nil {
165+
return "", fmt.Errorf("failed to stat stdin: %w", err)
166+
}
167+
168+
// If stdin is a terminal with no piped data, return an error
169+
if (stat.Mode() & os.ModeCharDevice) != 0 {
170+
return "", fmt.Errorf("no input provided on stdin (pipe content or redirect from a file)")
171+
}
172+
173+
reader := bufio.NewReader(os.Stdin)
174+
data, err := io.ReadAll(reader)
175+
if err != nil {
176+
return "", err
177+
}
178+
179+
// Trim trailing newline that's often added by echo/cat
180+
content := strings.TrimSuffix(string(data), "\n")
181+
return content, nil
182+
}
183+
184+
func getBuildAuthFileCmdFunc(_ *cobra.Command, args []string) error {
185+
provider := config.NewDefaultProvider()
186+
ctx := context.Background()
187+
188+
if len(args) == 1 {
189+
name := args[0]
190+
if !provider.IsBuildAuthFileConfigured(name) {
191+
fmt.Printf("Build auth file %s is not configured.\n", name)
192+
return nil
193+
}
194+
195+
// Get content from secrets if requested
196+
if showAuthFileContent {
197+
manager, err := getSecretsManager()
198+
if err != nil {
199+
return fmt.Errorf("failed to get secrets manager: %w", err)
200+
}
201+
secretName := config.BuildAuthFileSecretName(name)
202+
content, err := manager.GetSecret(ctx, secretName)
203+
if err != nil {
204+
return fmt.Errorf("failed to retrieve auth file content: %w", err)
205+
}
206+
lines := strings.Count(content, "\n") + 1
207+
fmt.Printf("%s: %d line(s) -> %s\n", name, lines, config.SupportedAuthFiles[name])
208+
fmt.Printf("Content:\n%s\n", content)
209+
} else {
210+
fmt.Printf("%s: configured -> %s\n", name, config.SupportedAuthFiles[name])
211+
}
212+
return nil
213+
}
214+
215+
configuredFiles := provider.GetConfiguredBuildAuthFiles()
216+
if len(configuredFiles) == 0 {
217+
fmt.Println("No build auth files are configured.")
218+
return nil
219+
}
220+
221+
sort.Strings(configuredFiles)
222+
223+
fmt.Println("Configured build auth files:")
224+
for _, name := range configuredFiles {
225+
if showAuthFileContent {
226+
manager, err := getSecretsManager()
227+
if err != nil {
228+
fmt.Printf(" %s: configured -> %s (unable to retrieve content: %v)\n",
229+
name, config.SupportedAuthFiles[name], err)
230+
continue
231+
}
232+
secretName := config.BuildAuthFileSecretName(name)
233+
content, err := manager.GetSecret(ctx, secretName)
234+
if err != nil {
235+
fmt.Printf(" %s: configured -> %s (unable to retrieve content: %v)\n",
236+
name, config.SupportedAuthFiles[name], err)
237+
continue
238+
}
239+
lines := strings.Count(content, "\n") + 1
240+
fmt.Printf(" %s: %d line(s) -> %s\n", name, lines, config.SupportedAuthFiles[name])
241+
fmt.Printf(" Content:\n%s\n", content)
242+
} else {
243+
fmt.Printf(" %s: configured -> %s\n", name, config.SupportedAuthFiles[name])
244+
}
245+
}
246+
return nil
247+
}
248+
249+
func unsetBuildAuthFileCmdFunc(_ *cobra.Command, args []string) error {
250+
provider := config.NewDefaultProvider()
251+
ctx := context.Background()
252+
253+
if unsetBuildAuthFileAll {
254+
configuredFiles := provider.GetConfiguredBuildAuthFiles()
255+
if len(configuredFiles) == 0 {
256+
fmt.Println("No build auth files are configured.")
257+
return nil
258+
}
259+
260+
// Try to get secrets manager to delete secrets (but don't fail if unavailable)
261+
manager, err := getSecretsManager()
262+
if err == nil {
263+
for _, name := range configuredFiles {
264+
secretName := config.BuildAuthFileSecretName(name)
265+
// Best effort - don't fail if secret doesn't exist
266+
_ = manager.DeleteSecret(ctx, secretName)
267+
}
268+
}
269+
270+
if err := provider.UnsetAllBuildAuthFiles(); err != nil {
271+
return fmt.Errorf("failed to remove build auth files: %w", err)
272+
}
273+
274+
fmt.Printf("Successfully removed %d build auth file(s).\n", len(configuredFiles))
275+
return nil
276+
}
277+
278+
if len(args) == 0 {
279+
return fmt.Errorf("please specify a file name or use --all")
280+
}
281+
282+
name := args[0]
283+
if !provider.IsBuildAuthFileConfigured(name) {
284+
fmt.Printf("Build auth file %s is not configured.\n", name)
285+
return nil
286+
}
287+
288+
// Try to delete the secret (but don't fail if secrets manager unavailable)
289+
manager, err := getSecretsManager()
290+
if err == nil {
291+
secretName := config.BuildAuthFileSecretName(name)
292+
_ = manager.DeleteSecret(ctx, secretName)
293+
}
294+
295+
if err := provider.UnsetBuildAuthFile(name); err != nil {
296+
return fmt.Errorf("failed to remove build auth file: %w", err)
297+
}
298+
299+
fmt.Printf("Successfully removed build auth file: %s\n", name)
300+
return nil
301+
}

docs/cli/thv_config.md

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/thv_config_get-build-auth-file.md

Lines changed: 50 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)