Skip to content

Commit 39d1db8

Browse files
committed
base env support in shell tools
1 parent 27faf5d commit 39d1db8

9 files changed

Lines changed: 805 additions & 136 deletions

File tree

README.md

Lines changed: 258 additions & 80 deletions
Large diffs are not rendered by default.

exectool/bootstrap.go

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
package exectool
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os/exec"
8+
"runtime"
9+
"strings"
10+
11+
"github.com/flexigpt/llmtools-go/internal/executil"
12+
"github.com/flexigpt/llmtools-go/internal/toolutil"
13+
)
14+
15+
// BootstrapDefaults best-effort detects the preferred host shell and a narrow,
16+
// tool-useful base environment suitable for command/script execution.
17+
func BootstrapDefaults(ctx context.Context) (*BootstrappedDefaults, error) {
18+
ctx, cancel := withBootstrapTimeout(ctx)
19+
defer cancel()
20+
21+
sel, err := selectShell(ctx, ShellNameAuto)
22+
if err != nil {
23+
return nil, err
24+
}
25+
26+
out := &BootstrappedDefaults{DefaultShell: sel.Name}
27+
env, envErr := bootstrapBaseEnv(ctx, sel)
28+
if envErr != nil {
29+
return out, envErr
30+
}
31+
out.BaseEnv = env
32+
return out, nil
33+
}
34+
35+
func normalizeShellName(shell ShellName) (ShellName, error) {
36+
s := ShellName(strings.ToLower(strings.TrimSpace(string(shell))))
37+
switch s {
38+
case ShellNameAuto,
39+
ShellNameBash,
40+
ShellNameZsh,
41+
ShellNameSh,
42+
ShellNameDash,
43+
ShellNameKsh,
44+
ShellNameFish,
45+
ShellNamePwsh,
46+
ShellNamePowershell,
47+
ShellNameCmd:
48+
return s, nil
49+
default:
50+
return "", fmt.Errorf("invalid shell: %q", shell)
51+
}
52+
}
53+
54+
func withBootstrapTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
55+
if ctx == nil {
56+
ctx = context.Background()
57+
}
58+
if _, ok := ctx.Deadline(); ok {
59+
return context.WithCancel(ctx)
60+
}
61+
return context.WithTimeout(ctx, defaultBootstrapTimeout)
62+
}
63+
64+
func bootstrapBaseEnv(ctx context.Context, sel executil.SelectedShell) (map[string]string, error) {
65+
args, err := bootstrapCommandArgs(sel)
66+
if err != nil {
67+
return nil, err
68+
}
69+
if len(args) == 0 {
70+
return nil, errors.New("invalid bootstrap command")
71+
}
72+
73+
//nolint:gosec // Bootstrap hand crafted.
74+
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
75+
out, err := cmd.CombinedOutput()
76+
if err != nil {
77+
return nil, fmt.Errorf("bootstrap env via %s failed: %w", sel.Name, err)
78+
}
79+
80+
raw, err := parseBootstrappedEnv(string(out))
81+
if err != nil {
82+
return nil, err
83+
}
84+
filtered := filterBootstrappedEnv(raw)
85+
if len(filtered) == 0 {
86+
return nil, errors.New("bootstrap env produced no usable variables")
87+
}
88+
if err := executil.ValidateEnvMap(filtered); err != nil {
89+
return nil, err
90+
}
91+
return filtered, nil
92+
}
93+
94+
func bootstrapCommandArgs(sel executil.SelectedShell) ([]string, error) {
95+
switch sel.Name {
96+
case ShellNameBash, ShellNameZsh:
97+
cmd := fmt.Sprintf(
98+
"printf '%%s\\n' '%s'; env; printf '%%s\\n' '%s'",
99+
bootstrapEnvBeginMarker,
100+
bootstrapEnvEndMarker,
101+
)
102+
return []string{sel.Path, "-lic", cmd}, nil
103+
case ShellNameFish:
104+
cmd := fmt.Sprintf(
105+
"printf '%%s\\n' '%s'; env; printf '%%s\\n' '%s'",
106+
bootstrapEnvBeginMarker,
107+
bootstrapEnvEndMarker,
108+
)
109+
return []string{sel.Path, "-l", "-i", "-c", cmd}, nil
110+
case ShellNameSh, ShellNameDash, ShellNameKsh:
111+
cmd := fmt.Sprintf(
112+
"printf '%%s\\n' '%s'; env; printf '%%s\\n' '%s'",
113+
bootstrapEnvBeginMarker,
114+
bootstrapEnvEndMarker,
115+
)
116+
return []string{sel.Path, "-lc", cmd}, nil
117+
case ShellNamePwsh, ShellNamePowershell:
118+
cmd := fmt.Sprintf(
119+
"Write-Output '%s'; Get-ChildItem Env: | ForEach-Object { '{0}={1}' -f $_.Name, $_.Value }; Write-Output '%s'",
120+
bootstrapEnvBeginMarker,
121+
bootstrapEnvEndMarker,
122+
)
123+
return []string{sel.Path, "-NoLogo", "-Command", cmd}, nil
124+
case ShellNameCmd:
125+
cmd := fmt.Sprintf("echo %s & set & echo %s", bootstrapEnvBeginMarker, bootstrapEnvEndMarker)
126+
return []string{sel.Path, "/d", "/s", "/c", cmd}, nil
127+
default:
128+
return nil, fmt.Errorf("unsupported shell for bootstrap: %s", sel.Name)
129+
}
130+
}
131+
132+
func parseBootstrappedEnv(out string) (map[string]string, error) {
133+
text := strings.ReplaceAll(out, "\r\n", "\n")
134+
lines := strings.Split(text, "\n")
135+
begin := -1
136+
end := -1
137+
for i, line := range lines {
138+
trimmed := strings.TrimSpace(line)
139+
if trimmed == bootstrapEnvBeginMarker && begin < 0 {
140+
begin = i
141+
continue
142+
}
143+
if trimmed == bootstrapEnvEndMarker && begin >= 0 {
144+
end = i
145+
break
146+
}
147+
}
148+
if begin < 0 || end <= begin {
149+
return nil, errors.New("could not parse bootstrapped env output")
150+
}
151+
152+
outMap := map[string]string{}
153+
for _, line := range lines[begin+1 : end] {
154+
if line == "" {
155+
continue
156+
}
157+
k, v, ok := strings.Cut(line, "=")
158+
if !ok {
159+
continue
160+
}
161+
kk := strings.TrimSpace(k)
162+
if kk == "" {
163+
continue
164+
}
165+
outMap[kk] = v
166+
}
167+
return outMap, nil
168+
}
169+
170+
func filterBootstrappedEnv(raw map[string]string) map[string]string {
171+
if len(raw) == 0 {
172+
return nil
173+
}
174+
175+
out := make(map[string]string)
176+
if runtime.GOOS == toolutil.GOOSWindows {
177+
exact := map[string]struct{}{
178+
"PATH": {},
179+
"PATHEXT": {},
180+
"SYSTEMROOT": {},
181+
"COMSPEC": {},
182+
"USERPROFILE": {},
183+
"HOMEDRIVE": {},
184+
"HOMEPATH": {},
185+
"HOME": {},
186+
"APPDATA": {},
187+
"LOCALAPPDATA": {},
188+
"PROGRAMDATA": {},
189+
"PROGRAMFILES": {},
190+
"PROGRAMFILES(X86)": {},
191+
"COMMONPROGRAMFILES": {},
192+
"COMMONPROGRAMFILES(X86)": {},
193+
"TMP": {},
194+
"TEMP": {},
195+
"ONEDRIVE": {},
196+
"CHOCOLATEYINSTALL": {},
197+
"JAVA_HOME": {},
198+
"GOBIN": {},
199+
"GOPATH": {},
200+
"GOROOT": {},
201+
"PNPM_HOME": {},
202+
"BUN_INSTALL": {},
203+
"CARGO_HOME": {},
204+
"RUSTUP_HOME": {},
205+
"VIRTUAL_ENV": {},
206+
"CONDA_PREFIX": {},
207+
"CONDA_DEFAULT_ENV": {},
208+
"CONDA_EXE": {},
209+
}
210+
prefixes := []string{"ASDF_", "PYENV_", "RBENV_", "NVM_", "VOLTA_", "SDKMAN_", "CONDA_"}
211+
for k, v := range raw {
212+
ck := strings.ToUpper(strings.TrimSpace(k))
213+
if _, ok := exact[ck]; ok || hasAnyPrefix(ck, prefixes) {
214+
out[k] = v
215+
}
216+
}
217+
return out
218+
}
219+
220+
exact := map[string]struct{}{
221+
"PATH": {},
222+
"HOME": {},
223+
"USER": {},
224+
"LOGNAME": {},
225+
"SHELL": {},
226+
"TMPDIR": {},
227+
"TMP": {},
228+
"TEMP": {},
229+
"LANG": {},
230+
"LC_ALL": {},
231+
"LC_CTYPE": {},
232+
"TERM": {},
233+
"COLORTERM": {},
234+
"XDG_CONFIG_HOME": {},
235+
"XDG_CACHE_HOME": {},
236+
"XDG_DATA_HOME": {},
237+
"GOBIN": {},
238+
"GOPATH": {},
239+
"GOROOT": {},
240+
"JAVA_HOME": {},
241+
"PNPM_HOME": {},
242+
"BUN_INSTALL": {},
243+
"CARGO_HOME": {},
244+
"RUSTUP_HOME": {},
245+
"VIRTUAL_ENV": {},
246+
"CONDA_PREFIX": {},
247+
"CONDA_DEFAULT_ENV": {},
248+
"CONDA_EXE": {},
249+
}
250+
prefixes := []string{"ASDF_", "PYENV_", "RBENV_", "NVM_", "VOLTA_", "SDKMAN_", "LC_", "CONDA_"}
251+
for k, v := range raw {
252+
ck := strings.TrimSpace(k)
253+
if _, ok := exact[ck]; ok || hasAnyPrefix(ck, prefixes) {
254+
out[k] = v
255+
}
256+
}
257+
return out
258+
}
259+
260+
func hasAnyPrefix(s string, prefixes []string) bool {
261+
for _, p := range prefixes {
262+
if strings.HasPrefix(s, p) {
263+
return true
264+
}
265+
}
266+
return false
267+
}

exectool/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ type execToolConfig struct {
2626
blockSymlinks bool
2727
blockedCommands map[string]struct{}
2828

29+
defaultShell ShellName
30+
defaultShellSet bool
31+
baseEnv map[string]string
32+
baseEnvSet bool
33+
2934
executionPolicy ExecutionPolicy
3035
runScriptPolicy RunScriptPolicy
3136
}
@@ -34,6 +39,9 @@ type execToolPolicy struct {
3439
fsPolicy fspolicy.FSPolicy
3540
blockedCommands map[string]struct{}
3641

42+
defaultShell ShellName
43+
baseEnv map[string]string
44+
3745
executionPolicy ExecutionPolicy
3846
runScriptPolicy RunScriptPolicy
3947
}
@@ -54,6 +62,8 @@ func (p *execToolPolicy) Clone() *execToolPolicy {
5462
cp.blockedCommands = nil
5563
}
5664

65+
cp.baseEnv = maps.Clone(p.baseEnv)
66+
5767
if c := p.executionPolicy.Clone(); c != nil {
5868
cp.executionPolicy = *c
5969
}

0 commit comments

Comments
 (0)