hooks/builtins: name the resolved shell in the env block

The session_start env block previously named the OS but not the shell.
When the model reads 'Operating System: Windows' it defaults to POSIX
syntax by habit and emits '&&', 'grep', 'head', '2>/dev/null', 'ls -la',
'$(pwd)' — none of which work under Windows PowerShell 5.1 or cmd.exe.
The shell tool description already carries a shell-specific hint
(shellSyntaxHint), but it sits far from the user turn and is drowned
out by the agent's own system prompt; the env block sits at the top
of every turn and is cached, so that is where the disambiguation
belongs.

Resolve the shell via the shared shellpath.DetectShell helper and print
its base name plus the resolved path. Long-form dialect rules stay in
the shell tool description (shellSyntaxHint) so this block stays terse
and doesn't diverge from them.

The base-name derivation lives in a new shellpath.ShellBaseName helper
so the same logic isn't copied into hooks/builtins.

No behaviour change on darwin/linux where the shell defaults to $SHELL
or /bin/sh; the extra 'Shell:' line lands cleanly in the existing
tab-indented env block.
This commit is contained in:
Trung Nguyen committed 2026-08-31 09:00:17 +02:00
1 parent 1a0e7ffbcb
commit dca687987e
4 files changed
+49 -5

No files matched your search

+8 -5
View File
@@ -6,13 +6,13 @@ import (
"runtime"
"github.com/docker/docker-agent/pkg/hooks"
"github.com/docker/docker-agent/pkg/shellpath"
)
// AddEnvironmentInfo is the registered name of the add_environment_info builtin.
const AddEnvironmentInfo = "add_environment_info"
// addEnvironmentInfo emits cwd / git / OS / arch info as session_start
// additional context. No-op when Cwd is empty.
// addEnvironmentInfo emits cwd/git/OS/arch/shell as session_start context.
func addEnvironmentInfo(_ context.Context, in *hooks.Input, _ []string) (*hooks.Output, error) {
if in == nil || in.Cwd == "" {
return nil, nil
@@ -20,20 +20,23 @@ func addEnvironmentInfo(_ context.Context, in *hooks.Input, _ []string) (*hooks.
return hooks.NewAdditionalContextOutput(hooks.EventSessionStart, environmentInfo(in.Cwd)), nil
}
// environmentInfo formats the env block injected at session_start:
// working directory, git-repo status, and human-readable OS / arch.
// environmentInfo builds the <env> block. Long-form dialect rules live in
// shellSyntaxHint (tool description) and shellDialectHint (reactive) so this
// stays terse.
func environmentInfo(workingDir string) string {
gitRepo := "No"
if isGitRepo(workingDir) {
gitRepo = "Yes"
}
shellPath, _ := shellpath.DetectShell()
return fmt.Sprintf(`Here is useful information about the environment you are running in:
<env>
Working directory: %s
Is directory a git repo: %s
Operating System: %s
CPU Architecture: %s
</env>`, workingDir, gitRepo, displayOS(), displayArch())
Shell: %s (%s)
</env>`, workingDir, gitRepo, displayOS(), displayArch(), shellpath.ShellBaseName(shellPath), shellPath)
}
// displayOS returns a friendlier label for the common values of
@@ -7,6 +7,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/shellpath"
)
func TestEnvironmentInfo(t *testing.T) {
@@ -49,12 +51,14 @@ func TestEnvironmentInfo(t *testing.T) {
if tt.expectGit {
gitStatus = "Yes"
}
shellPath, _ := shellpath.DetectShell()
expected := `Here is useful information about the environment you are running in:
<env>
Working directory: ` + dir + `
Is directory a git repo: ` + gitStatus + `
Operating System: ` + displayOS() + `
CPU Architecture: ` + displayArch() + `
Shell: ` + shellpath.ShellBaseName(shellPath) + ` (` + shellPath + `)
</env>`
assert.Equal(t, expected, environmentInfo(dir))
+11
View File
@@ -7,8 +7,19 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"
)
// ShellBaseName returns the lowercase shell name without extension. Splits on
// both separators so results are stable when the path came from another host OS.
func ShellBaseName(shellPath string) string {
base := shellPath
if i := strings.LastIndexAny(base, `/\`); i >= 0 {
base = base[i+1:]
}
return strings.ToLower(strings.TrimSuffix(base, filepath.Ext(base)))
}
// WindowsCmdExe returns the absolute path to cmd.exe on Windows using the
// SystemRoot environment variable (e.g. C:\Windows\System32\cmd.exe).
// This avoids resolving cmd.exe through PATH, which would be vulnerable
+26
View File
@@ -7,6 +7,32 @@ import (
"testing"
)
func TestShellBaseName(t *testing.T) {
t.Parallel()
tests := []struct {
path string
expected string
}{
{path: "/bin/zsh", expected: "zsh"},
{path: "/usr/local/bin/fish", expected: "fish"},
{path: "/bin/sh", expected: "sh"},
{path: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, expected: "powershell"},
{path: `C:\Program Files\PowerShell\7\pwsh.exe`, expected: "pwsh"},
{path: `C:\Windows\System32\cmd.exe`, expected: "cmd"},
{path: "", expected: ""},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
t.Parallel()
if got := ShellBaseName(tt.path); got != tt.expected {
t.Errorf("ShellBaseName(%q) = %q, want %q", tt.path, got, tt.expected)
}
})
}
}
func TestWindowsCmdExe_ComSpec(t *testing.T) {
t.Setenv("ComSpec", `C:\Custom\cmd.exe`)
got := WindowsCmdExe()