Files
2026-07-13 13:00:08 +08:00

475 lines
12 KiB
Go

// Package environment probes the local developer environment at startup and
// renders a small, stable model-facing summary.
package environment
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
"reasonix/internal/proc"
"reasonix/internal/secrets"
"reasonix/internal/shellparse"
)
const ProbeTimeout = 2 * time.Second
const probeWaitDelay = time.Second
const probeCacheTTL = 5 * time.Minute
const maxRenderedTools = 24
var probeTimeout = ProbeTimeout
type probeCacheEntry struct {
storedAt time.Time
results []ProbeResult
}
type probeInflight struct {
done chan struct{}
results []ProbeResult
}
var (
probeCacheMu sync.Mutex
probeCache = map[string]probeCacheEntry{}
probeInflightCalls = map[string]*probeInflight{}
probeNow = time.Now
)
type ProbeResult struct {
Command string
Binary string
Output string
Found bool
Error string
}
type ProbeOptions struct {
Overrides map[string]string
DenyRoots []string
// SnapshotDir, when set, persists probe results across process restarts
// (one snapshot per fingerprint under SnapshotDir/environment). A snapshot
// younger than probeSnapshotTTL is served without re-probing, and a
// refresh merges transient failures against the previous snapshot — both
// keep the rendered environment section byte-stable so the cached
// system-prompt prefix survives rebuilds. Empty disables persistence.
// The directory is host state, never model-visible, so it stays out of
// the probe fingerprint.
SnapshotDir string
}
func DefaultProbes() []string {
return []string{
"go version",
"python3 --version",
"python --version",
"node --version",
"npm --version",
"rustc --version",
"cargo --version",
"git version",
"make --version",
"rg --version",
"docker --version",
}
}
func RunProbes(ctx context.Context, commands []string) []ProbeResult {
return RunProbesWithOverrides(ctx, commands, nil)
}
func RunProbesWithOverrides(ctx context.Context, commands []string, overrides map[string]string) []ProbeResult {
return RunProbesWithOptions(ctx, commands, ProbeOptions{Overrides: overrides})
}
func RunProbesWithOptions(ctx context.Context, commands []string, opts ProbeOptions) []ProbeResult {
key := probeFingerprint(commands, opts)
now := probeNow()
if results, ok := cachedProbeResults(key, now); ok {
return results
}
if call, ok := beginProbe(key); ok {
<-call.done
return cloneProbeResults(call.results)
}
// A fresh persisted snapshot substitutes for a live run entirely: rebuilds
// and app relaunches within the TTL render the exact bytes the sessions on
// this machine were recorded with, so the provider prefix cache survives.
snapshot, hasSnapshot := loadProbeSnapshot(opts.SnapshotDir, key)
if hasSnapshot && now.Sub(snapshot.StoredAt) < probeSnapshotTTL {
finishProbe(key, snapshot.Results, now)
return cloneProbeResults(snapshot.Results)
}
results := runProbesUncached(ctx, commands, opts)
if hasSnapshot {
// Even an expired snapshot anchors the flap merge: transient failures
// (timeout, nonzero exit) keep the previous successful observation so
// a slow tool cannot rewrite the prompt prefix.
results = mergeProbeSnapshot(snapshot.Results, results)
}
saveProbeSnapshot(opts.SnapshotDir, key, results, now)
finishProbe(key, results, probeNow())
return cloneProbeResults(results)
}
func runProbesUncached(ctx context.Context, commands []string, opts ProbeOptions) []ProbeResult {
results := make([]ProbeResult, len(commands))
var wg sync.WaitGroup
for i, command := range commands {
wg.Add(1)
go func(i int, command string) {
defer wg.Done()
results[i] = runOne(ctx, command, opts)
}(i, command)
}
wg.Wait()
sortResults(results)
return results
}
func cachedProbeResults(key string, now time.Time) ([]ProbeResult, bool) {
probeCacheMu.Lock()
defer probeCacheMu.Unlock()
entry, ok := probeCache[key]
if !ok || now.Sub(entry.storedAt) >= probeCacheTTL {
if ok {
delete(probeCache, key)
}
return nil, false
}
return cloneProbeResults(entry.results), true
}
func beginProbe(key string) (*probeInflight, bool) {
probeCacheMu.Lock()
defer probeCacheMu.Unlock()
if call, ok := probeInflightCalls[key]; ok {
return call, true
}
probeInflightCalls[key] = &probeInflight{done: make(chan struct{})}
return nil, false
}
func finishProbe(key string, results []ProbeResult, now time.Time) {
probeCacheMu.Lock()
defer probeCacheMu.Unlock()
cached := cloneProbeResults(results)
probeCache[key] = probeCacheEntry{storedAt: now, results: cached}
if call, ok := probeInflightCalls[key]; ok {
call.results = cached
delete(probeInflightCalls, key)
close(call.done)
}
}
func probeFingerprint(commands []string, opts ProbeOptions) string {
var b strings.Builder
b.WriteString("v1")
for _, command := range commands {
b.WriteByte('\x00')
b.WriteString(strings.TrimSpace(command))
}
for _, name := range sortedMapKeys(opts.Overrides) {
b.WriteByte('\x00')
b.WriteString(name)
b.WriteByte('=')
b.WriteString(expandHome(opts.Overrides[name]))
}
for _, root := range normalizedDenyRoots(opts.DenyRoots) {
b.WriteByte('\x00')
b.WriteString("deny=")
b.WriteString(root)
}
return b.String()
}
func cloneProbeResults(results []ProbeResult) []ProbeResult {
if results == nil {
return nil
}
return append([]ProbeResult(nil), results...)
}
func runOne(ctx context.Context, command string, opts ProbeOptions) ProbeResult {
probe, err := shellparse.ParseStaticCommand(command, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
if err != nil {
return ProbeResult{Command: command, Binary: command, Error: "invalid command: " + err.Error()}
}
parts := probe.Argv
if len(parts) == 0 {
return ProbeResult{Command: command, Binary: command, Error: "empty command"}
}
res := ProbeResult{Command: command, Binary: parts[0]}
var exe string
if override := strings.TrimSpace(opts.Overrides[parts[0]]); override != "" {
exe = expandHome(override)
if !filepath.IsAbs(exe) {
res.Error = "not trusted"
return res
}
if !fileExecutable(exe) {
res.Error = "not found"
return res
}
} else {
found, err := exec.LookPath(parts[0])
if err != nil {
res.Error = "not found"
return res
}
exe = found
}
if blockedExecutable(exe, opts.DenyRoots) {
res.Error = "not trusted"
return res
}
cmdCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, exe, parts[1:]...)
// Always set the env explicitly: leaving cmd.Env nil would inherit the
// full process environment and bypass [secrets] filter_subprocess_env for
// probes that declare no extra variables of their own.
cmd.Env = append(secrets.ProcessEnv(), probe.Env...)
prepareProbeCommand(cmd)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
if probe.MergeStderr {
cmd.Stderr = &stdout
} else {
cmd.Stderr = &stderr
}
err = cmd.Run()
out := strings.TrimSpace(stdout.String())
if out == "" {
out = strings.TrimSpace(stderr.String())
}
if err != nil {
if cmdCtx.Err() == context.DeadlineExceeded {
res.Error = "timeout"
return res
}
if out == "" {
res.Error = "exit " + err.Error()
return res
}
res.Error = firstLine(out)
return res
}
res.Found = true
res.Output = firstLine(out)
return res
}
func prepareProbeCommand(cmd *exec.Cmd) {
proc.HideWindow(cmd)
proc.SetProcessGroupKill(cmd)
cmd.Cancel = func() error {
proc.KillTree(cmd)
return nil
}
cmd.WaitDelay = probeWaitDelay
}
func sortResults(results []ProbeResult) {
sort.Slice(results, func(i, j int) bool {
if results[i].Found != results[j].Found {
return results[i].Found
}
return results[i].Binary < results[j].Binary
})
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return strings.TrimRight(s[:i], "\r")
}
return s
}
func FormatSection(results []ProbeResult, osName, shellPath string, overrides map[string]string) string {
if len(results) == 0 && len(overrides) == 0 && osName == "" && shellPath == "" {
return ""
}
results = append([]ProbeResult(nil), results...)
sortResults(results)
var b strings.Builder
b.WriteString("## Environment\n\n")
if osName == "" {
osName = runtime.GOOS + "/" + runtime.GOARCH
}
b.WriteString("- OS: " + osName + "\n")
if shellPath != "" {
b.WriteString("- Shell: " + redactHome(shellPath) + "\n")
}
if len(overrides) > 0 {
b.WriteString("\nConfigured tools:\n")
names := sortedMapKeys(overrides)
for _, name := range limitStrings(names, maxRenderedTools) {
fmt.Fprintf(&b, "- %s: %s\n", name, redactHome(overrides[name]))
}
if omitted := len(names) - maxRenderedTools; omitted > 0 {
fmt.Fprintf(&b, "- ... %d more configured tools omitted\n", omitted)
}
}
if len(results) > 0 {
b.WriteString("\nDetected tools:\n")
foundShown := 0
foundTotal := 0
for _, r := range results {
if r.Found {
foundTotal++
if foundShown >= maxRenderedTools {
continue
}
out := r.Output
if out == "" {
out = "available"
}
fmt.Fprintf(&b, "- %s: %s\n", r.Binary, out)
foundShown++
}
}
if omitted := foundTotal - foundShown; omitted > 0 {
fmt.Fprintf(&b, "- ... %d more detected tools omitted\n", omitted)
}
b.WriteString("\nNot found or unavailable:\n")
missingShown := 0
missingTotal := 0
for _, r := range results {
if !r.Found {
missingTotal++
if missingShown >= maxRenderedTools {
continue
}
reason := r.Error
if reason == "" {
reason = "not found"
}
fmt.Fprintf(&b, "- %s: %s\n", r.Binary, reason)
missingShown++
}
}
if omitted := missingTotal - missingShown; omitted > 0 {
fmt.Fprintf(&b, "- ... %d more unavailable tools omitted\n", omitted)
}
}
b.WriteString("\nUse detected tools when appropriate. Do not try unavailable tools unless the user installs or configures them.\n")
return strings.TrimRight(b.String(), "\n")
}
func sortedMapKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
if strings.TrimSpace(k) != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
return keys
}
func limitStrings(in []string, limit int) []string {
if len(in) <= limit {
return in
}
return in[:limit]
}
func redactHome(path string) string {
path = expandHome(path)
if path == "" {
return ""
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.ToSlash(filepath.Clean(path))
}
clean := filepath.Clean(path)
home = filepath.Clean(home)
if clean == home {
return "~"
}
if strings.HasPrefix(clean, home+string(filepath.Separator)) {
return filepath.ToSlash("~" + strings.TrimPrefix(clean, home))
}
return filepath.ToSlash(clean)
}
func expandHome(path string) string {
path = strings.TrimSpace(path)
if path == "" || path == "~" || !strings.HasPrefix(path, "~/") {
return path
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return path
}
return filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
func fileExecutable(path string) bool {
fi, err := os.Stat(path)
return err == nil && !fi.IsDir()
}
func blockedExecutable(path string, denyRoots []string) bool {
abs, err := filepath.Abs(path)
if err != nil {
abs = filepath.Clean(path)
}
for _, root := range normalizedDenyRoots(denyRoots) {
if pathWithin(abs, root) {
return true
}
}
return false
}
func normalizedDenyRoots(roots []string) []string {
out := make([]string, 0, len(roots))
seen := map[string]bool{}
for _, root := range roots {
root = strings.TrimSpace(root)
if root == "" {
continue
}
root = expandHome(root)
abs, err := filepath.Abs(root)
if err != nil {
abs = filepath.Clean(root)
}
abs = filepath.Clean(abs)
if !seen[abs] {
seen[abs] = true
out = append(out, abs)
}
}
sort.Strings(out)
return out
}
func pathWithin(path, root string) bool {
path = filepath.Clean(path)
root = filepath.Clean(root)
if path == root {
return true
}
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel != "." && rel != "" && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
}