From 2c3fe7e572e77bc78670433461416a2f2abe4feb Mon Sep 17 00:00:00 2001 From: Jay Taylor Date: Tue, 16 Dec 2025 20:28:35 -0800 Subject: [PATCH 1/4] Fix opencode host dir resolution --- internal/configstore/config.go | 2 +- internal/configstore/homedir.go | 36 +++++++++++++++++++++++++++ internal/configstore/hostdirs.go | 9 +++---- internal/configstore/hostdirs_test.go | 29 +++++++++++++++++++++ internal/configstore/mounts.go | 9 +++---- internal/configstore/path.go | 9 +++---- 6 files changed, 75 insertions(+), 19 deletions(-) create mode 100644 internal/configstore/homedir.go diff --git a/internal/configstore/config.go b/internal/configstore/config.go index 2dfeaea..a69babb 100644 --- a/internal/configstore/config.go +++ b/internal/configstore/config.go @@ -164,7 +164,7 @@ func expandLeadingTilde(path string) (string, error) { } if len(path) == 1 || isPathSeparator(path[1]) { - home, err := os.UserHomeDir() + home, err := resolveHomeDir() if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } diff --git a/internal/configstore/homedir.go b/internal/configstore/homedir.go new file mode 100644 index 0000000..83f2017 --- /dev/null +++ b/internal/configstore/homedir.go @@ -0,0 +1,36 @@ +package configstore + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// resolveHomeDir evaluates HOME-style environment variables on each call to +// avoid relying on os.UserHomeDir's cached value, which can be stale in tests +// that mutate the process environment. +func resolveHomeDir() (string, error) { + home := strings.TrimSpace(os.Getenv("HOME")) + if home == "" { + drive := strings.TrimSpace(os.Getenv("HOMEDRIVE")) + path := strings.TrimSpace(os.Getenv("HOMEPATH")) + if drive != "" && path != "" { + home = filepath.Join(drive, path) + } else { + home = strings.TrimSpace(os.Getenv("USERPROFILE")) + } + } + if home != "" { + return filepath.Clean(home), nil + } + + resolved, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(resolved) == "" { + if err == nil { + err = fmt.Errorf("home directory not found") + } + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Clean(resolved), nil +} diff --git a/internal/configstore/hostdirs.go b/internal/configstore/hostdirs.go index f56d874..c122256 100644 --- a/internal/configstore/hostdirs.go +++ b/internal/configstore/hostdirs.go @@ -24,12 +24,9 @@ func HostDirForCommand(cmd string) (string, error) { if _, ok := supportedCommands[cmd]; !ok { return "", fmt.Errorf("unsupported command %q", cmd) } - home, err := os.UserHomeDir() - if err != nil || home == "" { - if err == nil { - err = fmt.Errorf("home directory not found") - } - return "", fmt.Errorf("resolve home dir: %w", err) + home, err := resolveHomeDir() + if err != nil { + return "", err } if cmd == "claude" { diff --git a/internal/configstore/hostdirs_test.go b/internal/configstore/hostdirs_test.go index d46caf0..9465027 100644 --- a/internal/configstore/hostdirs_test.go +++ b/internal/configstore/hostdirs_test.go @@ -30,6 +30,35 @@ func TestHostDirsAllCommandsCovered(t *testing.T) { } } +// This test primes os.UserHomeDir before updating HOME to ensure our resolution +// logic ignores the cached value. +func TestHostDirsIgnoreCachedHome(t *testing.T) { + testSetEnv(t, "LEASH_HOME", "") + testSetEnv(t, "CLAUDE_CONFIG_DIR", "") + testSetEnv(t, "XDG_CONFIG_HOME", "") + testSetEnv(t, "XDG_DATA_HOME", "") + testSetEnv(t, "XDG_STATE_HOME", "") + + originalHome := t.TempDir() + setHome(t, originalHome) + + if _, err := os.UserHomeDir(); err != nil { + t.Fatalf("prime os.UserHomeDir cache: %v", err) + } + + newHome := t.TempDir() + setHome(t, newHome) + + dir, err := HostDirForCommand("opencode") + if err != nil { + t.Fatalf("HostDirForCommand returned error: %v", err) + } + want := filepath.Join(newHome, ".config", "opencode") + if dir != want { + t.Fatalf("HostDirForCommand = %q, want %q", dir, want) + } +} + // This test modifies HOME while validating error handling, so it cannot run in // parallel with other environment-sensitive tests. func TestUnsupportedCommandPanics(t *testing.T) { diff --git a/internal/configstore/mounts.go b/internal/configstore/mounts.go index 12a974f..4705fe7 100644 --- a/internal/configstore/mounts.go +++ b/internal/configstore/mounts.go @@ -106,12 +106,9 @@ func ComputeExtraMountsFor(cmd string, outcome PromptOutcome, statFn func(string } func computeOpencodeMounts(outcome PromptOutcome, statFn func(string) (os.FileInfo, error)) ([]Mount, error) { - home, err := os.UserHomeDir() - if err != nil || strings.TrimSpace(home) == "" { - if err == nil { - err = fmt.Errorf("home directory not found") - } - return nil, fmt.Errorf("resolve home dir: %w", err) + home, err := resolveHomeDir() + if err != nil { + return nil, err } paths := opencodePaths(home) diff --git a/internal/configstore/path.go b/internal/configstore/path.go index c40a295..0b0bdc2 100644 --- a/internal/configstore/path.go +++ b/internal/configstore/path.go @@ -30,12 +30,9 @@ func GetConfigPath() (string, string, error) { return dir, filepath.Join(dir, configFileName), nil } - home, err := os.UserHomeDir() - if err != nil || strings.TrimSpace(home) == "" { - if err == nil { - err = fmt.Errorf("home directory not found") - } - return "", "", fmt.Errorf("resolve home dir: %w", err) + home, err := resolveHomeDir() + if err != nil { + return "", "", err } base = filepath.Join(home, ".config") dir := buildConfigDir(base) From a4dba1e69e6b041e99971ad5ec387fc7fd22780d Mon Sep 17 00:00:00 2001 From: Jay Taylor Date: Tue, 16 Dec 2025 20:44:07 -0800 Subject: [PATCH 2/4] Stabilize opencode host dir test env --- internal/configstore/hostdirs_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/configstore/hostdirs_test.go b/internal/configstore/hostdirs_test.go index 9465027..0529416 100644 --- a/internal/configstore/hostdirs_test.go +++ b/internal/configstore/hostdirs_test.go @@ -12,6 +12,9 @@ import ( func TestHostDirsAllCommandsCovered(t *testing.T) { testSetEnv(t, "LEASH_HOME", "") testSetEnv(t, "CLAUDE_CONFIG_DIR", "") + testSetEnv(t, "XDG_CONFIG_HOME", "") + testSetEnv(t, "XDG_DATA_HOME", "") + testSetEnv(t, "XDG_STATE_HOME", "") home := t.TempDir() setHome(t, home) From 06da32ebe93a37b2cce7cabcbf195e58d915dd6d Mon Sep 17 00:00:00 2001 From: Jay Taylor Date: Wed, 17 Dec 2025 10:06:47 -0800 Subject: [PATCH 3/4] Normalize TERM for bubbletea wizard --- internal/runner/bubbletea_prompter.go | 3 ++ internal/runner/terminfo.go | 32 ++++++++++++++++ internal/runner/terminfo_test.go | 53 +++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 internal/runner/terminfo.go create mode 100644 internal/runner/terminfo_test.go diff --git a/internal/runner/bubbletea_prompter.go b/internal/runner/bubbletea_prompter.go index dac5c82..9caedca 100644 --- a/internal/runner/bubbletea_prompter.go +++ b/internal/runner/bubbletea_prompter.go @@ -60,6 +60,9 @@ func newBubbleTeaPrompter(in io.Reader, out io.Writer, project string) *bubbleTe } func (p *bubbleTeaPrompter) ConfirmMount(ctx context.Context, cmd, hostDir string) (bool, error) { + restoreTerm := normalizeTERMForBubbleTea() + defer restoreTerm() + model := newWizardModel(cmd, hostDir, p.project, p.version, p.theme, p.logo) prog := tea.NewProgram(model, tea.WithInput(p.in), tea.WithOutput(p.out), tea.WithContext(ctx)) diff --git a/internal/runner/terminfo.go b/internal/runner/terminfo.go new file mode 100644 index 0000000..bb6e484 --- /dev/null +++ b/internal/runner/terminfo.go @@ -0,0 +1,32 @@ +package runner + +import ( + "os" + "strings" +) + +// normalizeTERMForBubbleTea maps compatible but uncommon TERM values (e.g. +// xterm-ghostty) to widely supported entries so Bubble Tea can rely on terminfo +// for key handling and styling. It returns a restore function to put TERM back. +func normalizeTERMForBubbleTea() func() { + const ghosttyTERM = "xterm-ghostty" + + term := strings.TrimSpace(os.Getenv("TERM")) + if term == "" || strings.EqualFold(term, "xterm-256color") { + return func() {} + } + if !strings.EqualFold(term, ghosttyTERM) { + return func() {} + } + + prev, existed := os.LookupEnv("TERM") + _ = os.Setenv("TERM", "xterm-256color") + + return func() { + if !existed { + _ = os.Unsetenv("TERM") + return + } + _ = os.Setenv("TERM", prev) + } +} diff --git a/internal/runner/terminfo_test.go b/internal/runner/terminfo_test.go new file mode 100644 index 0000000..23da6a5 --- /dev/null +++ b/internal/runner/terminfo_test.go @@ -0,0 +1,53 @@ +package runner + +import ( + "os" + "testing" +) + +func TestNormalizeTERMForBubbleTeaGhostty(t *testing.T) { + t.Parallel() + + prev, existed := os.LookupEnv("TERM") + if err := os.Setenv("TERM", "xterm-ghostty"); err != nil { + t.Fatalf("set TERM: %v", err) + } + t.Cleanup(func() { + if !existed { + _ = os.Unsetenv("TERM") + return + } + _ = os.Setenv("TERM", prev) + }) + + restore := normalizeTERMForBubbleTea() + defer restore() + + if got := os.Getenv("TERM"); got != "xterm-256color" { + t.Fatalf("TERM = %q, want xterm-256color", got) + } +} + +func TestNormalizeTERMForBubbleTeaPassthrough(t *testing.T) { + t.Parallel() + + original := "xterm-256color" + prev, existed := os.LookupEnv("TERM") + if err := os.Setenv("TERM", original); err != nil { + t.Fatalf("set TERM: %v", err) + } + t.Cleanup(func() { + if !existed { + _ = os.Unsetenv("TERM") + return + } + _ = os.Setenv("TERM", prev) + }) + + restore := normalizeTERMForBubbleTea() + defer restore() + + if got := os.Getenv("TERM"); got != original { + t.Fatalf("TERM changed to %q, want %q", got, original) + } +} From 08d4b012704e52eeffd20f84f9b09f391874f25e Mon Sep 17 00:00:00 2001 From: Jay Taylor Date: Wed, 7 Jan 2026 14:19:37 -0800 Subject: [PATCH 4/4] Add open env default and dev verbosity toggles --- Makefile | 35 +++++++++++++++++ internal/darwind/runtime_darwin.go | 8 ++-- internal/darwind/runtime_darwin_test.go | 24 ++++++++++++ internal/openflag/openflag.go | 28 +++++++++++++ internal/openflag/openflag_test.go | 52 +++++++++++++++++++++++++ internal/runner/runner.go | 14 ++++++- internal/runner/runner_args_test.go | 30 ++++++++++++++ 7 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 internal/openflag/openflag.go create mode 100644 internal/openflag/openflag_test.go diff --git a/Makefile b/Makefile index c7d7aff..07daa00 100644 --- a/Makefile +++ b/Makefile @@ -290,6 +290,32 @@ else GO_TEST_FLAGS := -v endif +DEV_COMMAND ?= codex shell + +DEV_VERBOSE_FLAG := +ifneq ($(strip $(V)),) +ifneq (,$(shell printf '%s' "$(V)" | grep -Eq '^(1|[Tt]|[Tt][rR][uU][eE])$$' && echo yes)) +DEV_VERBOSE_FLAG := --verbose +endif +endif +ifneq ($(strip $(VERBOSE)),) +ifneq (,$(shell printf '%s' "$(VERBOSE)" | grep -Eq '^(1|[Tt]|[Tt][rR][uU][eE])$$' && echo yes)) +DEV_VERBOSE_FLAG := --verbose +endif +endif + +DEV_TRACE_ENV := +ifneq ($(strip $(T)),) +ifneq (,$(shell printf '%s' "$(T)" | grep -Eq '^(1|[Tt]|[Tt][rR][uU][eE])$$' && echo yes)) +DEV_TRACE_ENV := TRACE=1 +endif +endif +ifneq ($(strip $(TRACE)),) +ifneq (,$(shell printf '%s' "$(TRACE)" | grep -Eq '^(1|[Tt]|[Tt][rR][uU][eE])$$' && echo yes)) +DEV_TRACE_ENV := TRACE=1 +endif +endif + .PHONY: test-unit test-go test-unit test-go: precommit ## Run Go unit tests (after UI build + LSM-generate steps) @echo 'running go tests...' @@ -348,3 +374,12 @@ clean-docker: .PHONY: clean clean: clean-go clean-ui clean-docker ## Remove build artifacts + +.PHONY: dev +dev: build ## Build leash and run a default dev command (V/VERBOSE=1|true adds --verbose; T/TRACE=1|true sets TRACE=1) + @set -euo pipefail; \ + TRACE_ENV="$(DEV_TRACE_ENV)"; \ + VERBOSE_FLAG="$(DEV_VERBOSE_FLAG)"; \ + CMD="$(DEV_COMMAND)"; \ + echo "$$TRACE_ENV ./bin/leash $$VERBOSE_FLAG -- $$CMD"; \ + $$TRACE_ENV ./bin/leash $$VERBOSE_FLAG -- $$CMD diff --git a/internal/darwind/runtime_darwin.go b/internal/darwind/runtime_darwin.go index 73e47d9..5d10b62 100644 --- a/internal/darwind/runtime_darwin.go +++ b/internal/darwind/runtime_darwin.go @@ -31,6 +31,7 @@ import ( "github.com/strongdm/leash/internal/lsm" "github.com/strongdm/leash/internal/macsync" "github.com/strongdm/leash/internal/messages" + "github.com/strongdm/leash/internal/openflag" "github.com/strongdm/leash/internal/policy" "github.com/strongdm/leash/internal/proxy" "github.com/strongdm/leash/internal/telemetry/statsig" @@ -224,9 +225,10 @@ func parseConfig(args []string) (*runtimeConfig, error) { wsPort := fs.String("ws-port", "18080", "WebSocket server port") serveAddr := fs.String("serve", "", "Serve Control UI and API on bind address (e.g. :18080, 0.0.0.0:8127)") - openBrowser := false - fs.BoolVar(&openBrowser, "open", false, "Open Control UI in default browser after startup") - fs.BoolVar(&openBrowser, "o", false, "Open Control UI in default browser after startup (shorthand)") + openDefault := openflag.Enabled() + openBrowser := openDefault + fs.BoolVar(&openBrowser, "open", openDefault, "Open Control UI in default browser after startup") + fs.BoolVar(&openBrowser, "o", openDefault, "Open Control UI in default browser after startup (shorthand)") historySize := fs.Int("history-size", 10000, "Number of events to keep in memory for new connections") cgroupFlag := fs.String("cgroup", "", "Cgroup path to monitor") diff --git a/internal/darwind/runtime_darwin_test.go b/internal/darwind/runtime_darwin_test.go index 2945897..e4805f3 100644 --- a/internal/darwind/runtime_darwin_test.go +++ b/internal/darwind/runtime_darwin_test.go @@ -90,6 +90,30 @@ func TestIsExecHelpRequest(t *testing.T) { } } +func TestParseConfigOpenDefaultsFromEnv(t *testing.T) { + t.Setenv("OPEN", "true") + + cfg, err := parseConfig(nil) + if err != nil { + t.Fatalf("parseConfig returned error: %v", err) + } + if !cfg.OpenBrowser { + t.Fatalf("expected OpenBrowser to be true when OPEN is truthy") + } +} + +func TestParseConfigOpenEnvOverriddenByFlag(t *testing.T) { + t.Setenv("OPEN", "true") + + cfg, err := parseConfig([]string{"--open=false"}) + if err != nil { + t.Fatalf("parseConfig returned error: %v", err) + } + if cfg.OpenBrowser { + t.Fatalf("expected OpenBrowser to be false when explicitly disabled via flag") + } +} + func TestPreFlightSetsDefaultPrivateDir(t *testing.T) { // t.Parallel avoided: this test mutates process-wide environment variables and // log sinks; running in parallel would race with other tests that rely on the diff --git a/internal/openflag/openflag.go b/internal/openflag/openflag.go new file mode 100644 index 0000000..2e22127 --- /dev/null +++ b/internal/openflag/openflag.go @@ -0,0 +1,28 @@ +package openflag + +import ( + "os" + "strings" +) + +// Enabled reports whether the OPEN environment variable requests that the +// Control UI be opened automatically. +func Enabled() bool { + value, ok := os.LookupEnv("OPEN") + if !ok { + return false + } + return IsTruthy(value) +} + +// IsTruthy returns true when the provided value matches an accepted truthy +// form for the OPEN environment variable. +func IsTruthy(value string) bool { + trimmed := strings.TrimSpace(value) + switch strings.ToLower(trimmed) { + case "1", "t", "true": + return true + default: + return false + } +} diff --git a/internal/openflag/openflag_test.go b/internal/openflag/openflag_test.go new file mode 100644 index 0000000..562b597 --- /dev/null +++ b/internal/openflag/openflag_test.go @@ -0,0 +1,52 @@ +package openflag + +import "testing" + +func TestIsTruthy(t *testing.T) { + t.Parallel() + + tests := []struct { + value string + expected bool + }{ + {value: "", expected: false}, + {value: "0", expected: false}, + {value: "false", expected: false}, + {value: "nope", expected: false}, + {value: "1", expected: true}, + {value: "t", expected: true}, + {value: "T", expected: true}, + {value: "true", expected: true}, + {value: "TRUE", expected: true}, + {value: " True ", expected: true}, + } + + for _, tt := range tests { + if got := IsTruthy(tt.value); got != tt.expected { + t.Fatalf("IsTruthy(%q) = %v, want %v", tt.value, got, tt.expected) + } + } +} + +func TestEnabledReadsEnvironment(t *testing.T) { + // Avoid t.Parallel because environment variables are process-wide. + t.Setenv("OPEN", "1") + if !Enabled() { + t.Fatalf("Enabled() = false with OPEN=1") + } + + t.Setenv("OPEN", "t") + if !Enabled() { + t.Fatalf("Enabled() = false with OPEN=t") + } + + t.Setenv("OPEN", "True") + if !Enabled() { + t.Fatalf("Enabled() = false with OPEN=True") + } + + t.Setenv("OPEN", "0") + if Enabled() { + t.Fatalf("Enabled() = true with OPEN=0") + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 43f195e..2716d50 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -23,6 +23,7 @@ import ( "github.com/strongdm/leash/internal/configstore" "github.com/strongdm/leash/internal/entrypoint" "github.com/strongdm/leash/internal/leashd/listen" + "github.com/strongdm/leash/internal/openflag" "github.com/strongdm/leash/internal/telemetry/statsig" ) @@ -187,6 +188,8 @@ func execute(cmdName string, args []string) error { return err } + applyOpenEnv(&opts) + if len(opts.command) == 0 { return fmt.Errorf("a command is required; provide one after '--'") } @@ -310,7 +313,7 @@ Flags: -P, --publish-all Publish all EXPOSEd ports (host same as container when free, auto-bump on conflicts). --image Override the target container image (defaults to %s). --leash-image Override the leash manager image (defaults to %s). - -V, --verbose Enable verbose logging. + -V, --verbose Enable verbose logging (also set when -v is provided without a mount spec). Environment variables: LEASH_TARGET_IMAGE Default target image (overridden by --image). @@ -513,6 +516,15 @@ func finalizeOptions(opts options) (options, error) { return opts, nil } +func applyOpenEnv(opts *options) { + if opts == nil || opts.openUI { + return + } + if openflag.Enabled() { + opts.openUI = true + } +} + func appendEnvSpec(opts *options, spec string) error { spec = strings.TrimSpace(spec) if spec == "" { diff --git a/internal/runner/runner_args_test.go b/internal/runner/runner_args_test.go index 76ea28a..6f4813b 100644 --- a/internal/runner/runner_args_test.go +++ b/internal/runner/runner_args_test.go @@ -150,6 +150,36 @@ func TestParseArgsOpenFlag(t *testing.T) { } } +func TestApplyOpenEnv(t *testing.T) { + clearEnv(t, "OPEN") + + opts := options{} + applyOpenEnv(&opts) + if opts.openUI { + t.Fatalf("expected openUI to remain false when OPEN is unset") + } + + setEnv(t, "OPEN", "1") + opts = options{} + applyOpenEnv(&opts) + if !opts.openUI { + t.Fatalf("expected openUI to be enabled when OPEN=1") + } + + opts = options{openUI: true} + applyOpenEnv(&opts) + if !opts.openUI { + t.Fatalf("expected existing openUI to stay true") + } + + setEnv(t, "OPEN", "false") + opts = options{} + applyOpenEnv(&opts) + if opts.openUI { + t.Fatalf("expected openUI to remain false when OPEN is not truthy") + } +} + func TestParseArgsEnvironmentMissingValue(t *testing.T) { t.Parallel()