env: centralize daemon env in pkg/env; clean up session env propagation

- New pkg/env package owns EnsureDefaults, Set, Get, All, Format
- BuildAgentEnv reverted to 3-arg signature (no initialEnv)
- agentCore.setEnv removed; pushSessionEnv handles OLLIE_SESSION_ID only
- execute.Server.SetEnv no longer calls os.Setenv (session-scoped only)
- !cmd handler sets OLLIE_SESSION_ID explicitly in subprocess env
- mcp/transport: seed cmd.Env from os.Environ() so MCP servers inherit PATH etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-18 15:43:01 +02:00
parent 2695567aa4
commit 44bff71a65
4 changed files with 110 additions and 61 deletions

View File

@ -243,14 +243,14 @@ type actionHandle struct {
// AgentCoreConfig is the configuration for creating an agentCore.
type AgentCoreConfig struct {
Backend backend.Backend
ModelName string // if non-empty, overrides backend's default model
AgentName string
AgentsDir string
SessionsDir string
SessionID string
CWD string // working directory for tool execution and system prompt
Session *Session
Backend backend.Backend
ModelName string // if non-empty, overrides backend's default model
AgentName string
AgentsDir string
SessionsDir string
SessionID string
CWD string // working directory for tool execution and system prompt
Session *Session
Env AgentEnv
NewDispatcher func() tools.Dispatcher
}
@ -267,7 +267,7 @@ type agentCore struct {
sessionID string
dispatcher tools.Dispatcher
newDispatcher func() tools.Dispatcher
cwd string
cwd string
agentPrompt string // agent-specific prompt suffix; kept for system prompt rebuilds
currentAction atomic.Pointer[actionHandle]
fifo PromptFIFO
@ -277,6 +277,19 @@ type agentCore struct {
reply string // assistant text from the most recently completed turn
}
// pushSessionEnv injects session-scoped vars into the execute server's
// subprocess environment without touching the process-global env.
func (s *agentCore) pushSessionEnv() {
if s.dispatcher == nil || s.sessionID == "" {
return
}
if srv, ok := s.dispatcher.GetServer("execute"); ok {
if es, ok := srv.(tools.EnvSetter); ok {
es.SetEnv("OLLIE_SESSION_ID", s.sessionID)
}
}
}
var _ Core = (*agentCore)(nil) // compile-time interface check
// NewAgentCore creates an agentCore from the given configuration.
@ -293,32 +306,12 @@ func NewAgentCore(cfg AgentCoreConfig) Core {
}
if cfg.SessionID != "" {
os.MkdirAll("/tmp/ollie/"+cfg.SessionID, 0700) //nolint:errcheck
}
// Inject session env into the execute server so OLLIE_SESSION_ID and OLLIE
// are available to tool scripts from the very first turn, not just after rename.
if cfg.Env.dispatcher != nil && cfg.SessionID != "" {
if srv, ok := cfg.Env.dispatcher.GetServer("execute"); ok {
if es, ok := srv.(tools.EnvSetter); ok {
es.SetEnv("OLLIE_SESSION_ID", cfg.SessionID)
ollie := os.Getenv("OLLIE")
if ollie == "" {
home, _ := os.UserHomeDir()
ollie = home + "/mnt/ollie"
}
es.SetEnv("OLLIE", ollie)
tmpPath := os.Getenv("OLLIE_TMP_PATH")
if tmpPath == "" {
home, _ := os.UserHomeDir()
tmpPath = home + "/.local/share/ollie/tmp"
}
os.MkdirAll(tmpPath, 0700) //nolint:errcheck
es.SetEnv("OLLIE_TMP_PATH", tmpPath)
}
if p := os.Getenv("OLLIE_TMP_PATH"); p != "" {
os.MkdirAll(p, 0700) //nolint:errcheck
}
}
return &agentCore{
a := &agentCore{
session: cfg.Session,
loopcfg: loopcfg,
hooks: cfg.Env.Hooks,
@ -332,6 +325,8 @@ func NewAgentCore(cfg AgentCoreConfig) Core {
newDispatcher: cfg.NewDispatcher,
state: "idle",
}
a.pushSessionEnv()
return a
}
// Close releases resources for this session, including its tmpdir.
@ -443,27 +438,7 @@ func (s *agentCore) SetSessionID(newID string) error {
if _, err := os.Stat(oldTemp); err == nil {
os.Rename(oldTemp, newTemp) //nolint:errcheck
}
// Propagate to the execute server env.
if s.dispatcher != nil {
if srv, ok := s.dispatcher.GetServer("execute"); ok {
if es, ok := srv.(tools.EnvSetter); ok {
es.SetEnv("OLLIE_SESSION_ID", newID)
// Ensure OLLIE is always set, resolving the default if unset.
ollie := os.Getenv("OLLIE")
if ollie == "" {
home, _ := os.UserHomeDir()
ollie = home + "/mnt/ollie"
}
es.SetEnv("OLLIE", ollie)
tmpPath := os.Getenv("OLLIE_TMP_PATH")
if tmpPath == "" {
home, _ := os.UserHomeDir()
tmpPath = home + "/.local/share/ollie/tmp"
}
es.SetEnv("OLLIE_TMP_PATH", tmpPath)
}
}
}
s.pushSessionEnv()
return nil
}
@ -802,6 +777,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve
}
cmd := exec.Command("sh", "-c", cmdStr)
cmd.Dir = s.CWD()
cmd.Env = append(os.Environ(), "OLLIE_SESSION_ID="+s.sessionID)
o, err := cmd.CombinedOutput()
if err != nil {
handler(infoEvent("error: " + err.Error()))
@ -939,6 +915,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve
s.agentName = name
s.session = nil
s.sessionID = NewSessionID()
s.pushSessionEnv()
for _, msg := range env.Messages {
handler(infoEvent(msg))
}

71
pkg/env/env.go vendored Normal file
View File

@ -0,0 +1,71 @@
// Package env manages the daemon-global environment for ollie.
// It owns the set of known OLLIE_* and SUPERPOWERD_* variables, provides
// defaults, and formats them for export to frontends via ollie/env.
package env
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// managed is the ordered list of env vars that ollie exposes to frontends.
var managed = []string{
"OLLIE",
"OLLIE_TOOLS_PATH",
"OLLIE_SKILLS_PATH",
"OLLIE_PLAN_PATH",
"OLLIE_MEMORY_PATH",
"OLLIE_TMP_PATH",
"OLLIE_TRANSCRIPT_PATH",
"SUPERPOWERD_SESSION_TOKEN",
"SUPERPOWERD_SOCKET_DIR",
}
// EnsureDefaults sets default values for any OLLIE_* vars not already present
// in the process environment.
func EnsureDefaults() {
home, _ := os.UserHomeDir()
defaults := map[string]string{
"OLLIE": filepath.Join(home, "mnt", "ollie"),
"OLLIE_TOOLS_PATH": filepath.Join(home, ".config", "ollie", "tools"),
"OLLIE_SKILLS_PATH": filepath.Join(home, ".config", "ollie", "skills"),
"OLLIE_PLAN_PATH": filepath.Join(home, ".config", "ollie", "planning"),
"OLLIE_MEMORY_PATH": filepath.Join(home, ".config", "ollie", "memory"),
"OLLIE_TMP_PATH": filepath.Join(home, ".local", "share", "ollie", "tmp"),
"OLLIE_TRANSCRIPT_PATH": filepath.Join(home, ".config", "ollie", "transcript"),
}
for k, v := range defaults {
if os.Getenv(k) == "" {
os.Setenv(k, v) //nolint:errcheck
}
}
}
// Set sets a variable in the process environment.
func Set(k, v string) { os.Setenv(k, v) } //nolint:errcheck
// Get returns a variable from the process environment.
func Get(k string) string { return os.Getenv(k) }
// All returns the full process environment as a map.
func All() map[string]string {
pairs := os.Environ()
m := make(map[string]string, len(pairs))
for _, pair := range pairs {
k, v, _ := strings.Cut(pair, "=")
m[k] = v
}
return m
}
// Format returns the full process environment as NAME=VALUE lines, suitable
// for serving as ollie/env.
func Format() []byte {
var sb strings.Builder
for _, pair := range os.Environ() {
fmt.Fprintf(&sb, "%s\n", pair)
}
return []byte(sb.String())
}

View File

@ -21,8 +21,11 @@ func NewSTDIOTransport(command string, args []string, env map[string]string) *ST
// Connect launches the subprocess and returns a connected Client.
func (t *STDIOTransport) Connect() (*Client, error) {
cmd := exec.Command(t.command, t.args...)
for k, v := range t.env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, os.ExpandEnv(v)))
if len(t.env) > 0 {
cmd.Env = os.Environ()
for k, v := range t.env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, os.ExpandEnv(v)))
}
}
stdin, err := cmd.StdinPipe()

View File

@ -86,11 +86,11 @@ Examples:
"properties": {
"code": {"type": "string", "description": "Inline code to execute."},
"language": {"type": "string", "description": "Language interpreter (default: bash). Ignored when tool or parallel is set."},
"tool": {"type": "string", "description": "Named tool script from the tools directory."},
"tool": {"type": "string", "description": "Named tool script from the tools directory. Discover available tools: grep -iA2 'keyword' $OLLIE/t/idx"},
"args": {"type": "array", "items": {"type": "string"}, "description": "Arguments for the tool script."},
"parallel": {
"type": "array",
"description": "Fan-out: steps run concurrently, outputs concatenated in submission order, result fed to next stage.",
"description": "Fan-out: steps run concurrently, outputs concatenated in submission order, result fed to next stage. All parallel steps should produce the same output schema so the concatenated result is coherent.",
"items": {
"type": "object",
"properties": {
@ -162,11 +162,9 @@ func (e *Server) allowed(tool, detail string) bool {
return e.Confirm(detail)
}
// SetEnv adds a per-session environment variable to all subsequent commands.
// It also exports the variable to the process environment so it is inherited
// by sandboxed subprocesses that read os.Environ().
// SetEnv adds a session-scoped environment variable injected into all
// subsequent subprocess invocations for this session.
func (e *Server) SetEnv(key, value string) {
os.Setenv(key, value) //nolint:errcheck
e.envMu.Lock()
if e.envExtra == nil {
e.envExtra = make(map[string]string)