decouple session and agent files in 9P tree

Session-level files (session/{id}/): plan, env, ctl
Agent-level files (session/{id}/agent/{aid}/): cwd, prompt, log, chat,
  cfg, state, statewait, usage, cost, ctxsz, models, systemprompt,
  tail, prompt.prev, context, toolsrv.loaded, toolsrv.rev, tools

Key changes:
- Split sessionHelper into sessionHelper and agentHelper types
- ctl moved to session level (kill, rn, save, invalidate)
- cwd moved to agent level (read/write)
- Agent cfg no longer supports rename (use session/ctl rn)
- Permissions updated: cwd=0666, ctl=0666, env=0444 at session level
- Removed duplicate envContent method
This commit is contained in:
Ollie Agent 2026-08-01 13:57:05 +02:00
parent 3046afd8ed
commit fb1d10141b
3 changed files with 285 additions and 57 deletions

View File

@ -0,0 +1,174 @@
# Default execute_code sandbox configuration.
filesystem:
ro:
- "{XDG_CONFIG_HOME}/git"
- "{HOME}/.netrc"
- "/etc/gitconfig"
- "/etc/gitattributes"
- "/etc/ssh/ssh_config"
- "/etc/ssh/ssh_known_hosts"
- "/etc/magic"
- "/var/log"
- "/run/systemd"
- "/var/log/journal"
- "/etc/systemd"
- "/proc"
- "/sys"
- "{HOME}/.jenkins"
- "{HOME}/.psqlrc"
- "{HOME}/.pgpass"
- "{HOME}/.aws"
- "{HOME}/.ansible"
- "{HOME}/.ansible.cfg"
- "{HOME}/.jira.d"
- "{HOME}/.my.cnf"
- "{HOME}/.mylogin.cnf"
- "{XDG_CONFIG_HOME}/gh"
- "{XDG_DATA_HOME}/gh/extensions"
- "/dev"
- "{HOME}/img"
- "{OLLIE_CFG_PATH}"
rw:
- "{OLLIE_MEMORY_PATH}"
- "{OLLIE_PLAN_PATH}"
- "{HOME}/.ssh/known_hosts"
- "{HOME}/pCloudDrive"
- "{HOME}/.pcloud"
- "{HOME}/.git-credentials"
- "{XDG_CONFIG_HOME}/git/credentials"
- "{XDG_RUNTIME_DIR}/git/"
- "{HOME}/.mcpt"
- "{HOME}/doc"
- "{HOME}/.cache/mise"
- "{HOME}/.locale/state/mise"
- "{HOME}/.gnupg"
- "{XDG_DOCUMENTS_DIR}"
- "{XDG_CONFIG_HOME}/.jira"
- "{HOME}/.m2"
- "{HOME}/.gradle"
- "/var/run/docker.sock"
- "{HOME}/.docker"
- "{HOME}/.pnpm-store"
- "{XDG_CONFIG_HOME}/yarn"
- "{HOME}/.yarnrc.yml"
- "{HOME}/.npmrc"
- "{HOME}/.ansible/tmp"
- "{XDG_RUNTIME_DIR}"
- "{XDG_DATA_HOME}/containers"
- "{XDG_CONFIG_HOME}/containers"
- "{XDG_CONFIG_HOME}/pip"
- "{HOME}/.pip"
- "{HOME}/.cargo"
- "{HOME}/.rustup"
- "{XDG_CONFIG_HOME}/nvim"
- "{XDG_CONFIG_HOME}/vim"
- "{XDG_DATA_HOME}/nvim"
- "{XDG_STATE_HOME}/nvim"
- "{OLLIE_MEMORY_PATH}"
- "{OLLIE_PLAN_PATH}"
- "{HOME}/.vimrc"
- "{HOME}/.vim"
- "{HOME}/.emacs.d"
- "{HOME}/.config/emacs"
- "/etc"
- "/usr/local/etc"
- "/dev/urandom"
- "/dev/random"
- "/dev/null"
- "/dev/fuse"
- "{OLLIE_DATA_PATH}"
- "{HOME}/notes"
rox:
- "/usr"
- "/lib"
- "/lib64"
- "/bin"
- "/sbin"
- "{HOME}/go/bin"
- "{HOME}/env"
- "/proc/self/fd"
- "/proc/self/cmdline"
- "{OLLIE_TOOLS_PATH}"
rwx:
- "{CWD}"
- "{HOME}/Qt"
- "{HOME}/.local/share/pnpm"
- "{HOME}/.local/share/pipx"
- "{HOME}/.local/share/aqt/"
- "{HOME}/prj/aicoder"
- "{TMPDIR}"
- "{PLAN9}"
- "{HOME}/.cache/uv"
- "{HOME}/bin"
- "{OLLIE}"
- "{OLLIE_CFG_PATH}"
- "{OLLIE_DATA_PATH}"
- "{OLLIE_MEMORY_PATH}"
- "{OLLIE_PLAN_PATH}"
- "{OLLIE_SKILLS_PATH}"
- "{OLLIE_TMP_PATH}"
- "{OLLIE_TOOLS_PATH}"
- "{OLLIE_TRANSCRIPT_PATH}"
- "{OLLIE_AGENTS_PATH}"
- "{OLLIE_PROMPTS_PATH}"
# XDG defaults for OLLIE_* paths (in case env vars are unset)
- "{XDG_CONFIG_HOME}/ollie"
- "{XDG_DATA_HOME}/ollie"
- "{HOME}/.pyenv"
- "{HOME}/.local/bin"
- "{HOME}/.sdkman"
- "{HOME}/go"
- "{HOME}/.cache/go-build"
- "{HOME}/opt"
- "{HOME}/src"
- "{HOME}/prj"
- "{HOME}/.nvm"
- "{HOME}/.please"
- "{HOME}/.npm"
- "{HOME}/.mcp-auth"
- "{HOME}/.yarn"
- "{HOME}/.local/share/mise"
- "{PCFS}"
- "{XDG_RUNTIME_DIR}/{WAYLAND_DISPLAY}"
- "{HOME}/ws"
env:
- PATH
- NAMESPACE
- OLLIE
- OLLIE_DEFAULT_AGENT
- OLLIE_CFG_PATH
- OLLIE_DATA_PATH
- OLLIE_MEMORY_PATH
- OLLIE_PLAN_PATH
- OLLIE_SESSION_ID
- OLLIE_SKILLS_PATH
- OLLIE_TMP_PATH
- OLLIE_ELEVATE_SOCKET
- OLLIE_TOOLS_PATH
- OLLIE_TRANSCRIPT_PATH
- OLLIE_AGENTS_PATH
- OLLIE_PROMPTS_PATH
- OLLIE_UNAME
- OLLIE_LOGSEQ_TOKEN
- HOME
- TMPDIR
- PLAN9
- JENKINS_USER_ID
- JENKINS_API_TOKEN
- JENKINS_MCP_AUTH
- JIRA_API_TOKEN
- BITBUCKET_ACCESS_TOKEN
- BITBUCKET_TOKEN
- BITBUCKET_USERNAME
- PCLOUD_USER
- EDITOR
- GIT_SEQUENCE_EDITOR
- DISPLAY
- DBUS_SESSION_BUS_ADDRESS
- PCFS
- SSH_AUTH_SOCK
- XDG_SESSION_TYPE
- XDG_RUNTIME_DIR
- WAYLAND_DISPLAY
network:
unrestricted: true

View File

@ -22,6 +22,7 @@ var SessionFileList = []struct {
}{
{"plan", false, false},
{"env", false, false},
{"ctl", false, true},
}
// AgentFileList defines agent-level files.
@ -30,7 +31,7 @@ var AgentFileList = []struct {
OneShot bool
Async bool
}{
{"ctl", false, true},
{"cwd", false, false},
{"prompt", false, true},
{"fifo.in", false, true},
{"fifo.out", true, false},
@ -75,7 +76,7 @@ func NewSessionTree(sess *Session, log *olog.Logger, kill func(), rename func(ne
// NewAgentTree builds the file tree for a single agent within a session.
func NewAgentTree(sess *Session, log *olog.Logger, saveTranscript func([]byte) error, invalidateModels func(), toolRegistry *toolsrv.Registry) *Tree {
h := &sessionHelper{sess: sess, log: log, saveTranscript: saveTranscript, invalidateModels: invalidateModels, toolRegistry: toolRegistry}
h := &agentHelper{sess: sess, log: log, saveTranscript: saveTranscript, invalidateModels: invalidateModels, toolRegistry: toolRegistry}
perms := sessionFilePerms()
specs := make([]FileSpec, len(AgentFileList))
for i, f := range AgentFileList {
@ -97,10 +98,18 @@ type sessionHelper struct {
toolRegistry *toolsrv.Registry
}
// agentHelper holds the dependencies needed to build agent FileSpecs.
type agentHelper struct {
sess *Session
log *olog.Logger
saveTranscript func([]byte) error
invalidateModels func()
toolRegistry *toolsrv.Registry
}
func (h *sessionHelper) fileSpec(name string, mode os.FileMode) FileSpec {
fs := FileSpec{Name: name, Mode: mode}
// Read
switch name {
case "plan":
fs.Read = func() ([]byte, error) {
@ -116,6 +125,80 @@ func (h *sessionHelper) fileSpec(name string, mode os.FileMode) FileSpec {
h.sess.mu.RUnlock()
return int64(n)
}
fs.Write = func(data []byte) error {
h.sess.mu.Lock()
h.sess.plan = make([]byte, len(data))
copy(h.sess.plan, data)
h.sess.mu.Unlock()
return nil
}
case "env":
fs.Read = func() ([]byte, error) { return []byte(h.envContent()), nil }
case "ctl":
fs.Write = func(data []byte) error {
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
return h.handleCtl(input)
}
}
return fs
}
func (h *sessionHelper) envContent() string {
var sb strings.Builder
fmt.Fprintf(&sb, "OLLIE_SESSION_ID=%s\n", h.sess.RunnableID())
for _, e := range os.Environ() {
if strings.HasPrefix(e, "OLLIE_") && !strings.HasPrefix(e, "OLLIE_SESSION_ID=") {
sb.WriteString(e)
sb.WriteByte('\n')
}
}
return sb.String()
}
func (h *sessionHelper) handleCtl(input string) error {
cmd := strings.Fields(input)
if len(cmd) == 0 {
return fmt.Errorf("empty ctl command")
}
switch cmd[0] {
case "kill", ".":
h.kill()
case "rn":
if name := strings.TrimSpace(input[3:]); name != "" {
if err := h.rename(name); err != nil {
h.log.Error("rename: %v", err)
}
}
case "save":
h.sess.mu.RLock()
data := make([]byte, len(h.sess.log))
copy(data, h.sess.log)
h.sess.mu.RUnlock()
return h.saveTranscript(data)
case "invalidate":
if h.invalidateModels != nil {
h.invalidateModels()
}
h.sess.InvalidateModelsCache()
default:
return fmt.Errorf("unknown session ctl command: %s", cmd[0])
}
return nil
}
func (h *agentHelper) fileSpec(name string, mode os.FileMode) FileSpec {
fs := FileSpec{Name: name, Mode: mode}
// Read
switch name {
case "cwd":
fs.Read = func() ([]byte, error) {
return []byte(h.sess.Core.CWD() + "\n"), nil
}
case "prompt.prev":
fs.Read = func() ([]byte, error) {
h.sess.mu.RLock()
@ -164,13 +247,13 @@ func (h *sessionHelper) fileSpec(name string, mode os.FileMode) FileSpec {
// Write
switch name {
case "plan":
case "cwd":
fs.Write = func(data []byte) error {
h.sess.mu.Lock()
h.sess.plan = make([]byte, len(data))
copy(h.sess.plan, data)
h.sess.mu.Unlock()
return nil
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
return h.sess.Core.SetCWD(input)
}
case "log":
fs.Write = func(data []byte) error {
@ -212,14 +295,6 @@ func (h *sessionHelper) fileSpec(name string, mode os.FileMode) FileSpec {
h.sess.Core.Agent().Queue(input)
return nil
}
case "ctl":
fs.Write = func(data []byte) error {
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
return h.handleCtl(input)
}
case "cfg":
fs.Write = func(data []byte) error {
input := strings.TrimSpace(string(data))
@ -283,7 +358,6 @@ func (h *sessionHelper) fileSpec(name string, mode os.FileMode) FileSpec {
for len(h.sess.log) <= offset {
if ctx.Err() != nil {
h.sess.mu.RUnlock()
// Context cancelled (session killed or connection closed) → EOF
return nil, "", nil
}
h.sess.chatCond.Wait()
@ -303,6 +377,16 @@ func (h *sessionHelper) fileSpec(name string, mode os.FileMode) FileSpec {
}
func (h *sessionHelper) content(name string) string {
h.sess.mu.RLock()
defer h.sess.mu.RUnlock()
switch name {
case "env":
return h.envContent()
}
return ""
}
func (h *agentHelper) content(name string) string {
h.sess.mu.RLock()
defer h.sess.mu.RUnlock()
switch name {
@ -321,8 +405,6 @@ func (h *sessionHelper) content(name string) string {
return h.sess.Core.Agent().State() + "\n"
case "offset":
return fmt.Sprintf("%d\n", h.sess.ChatOffset)
case "env":
return h.envContent()
case "tail":
return "#!/bin/sh\nexec tail -f \"$(dirname \"$0\")/chat\"\n"
case "context":
@ -372,19 +454,7 @@ func (h *sessionHelper) content(name string) string {
return ""
}
func (h *sessionHelper) envContent() string {
var sb strings.Builder
fmt.Fprintf(&sb, "OLLIE_SESSION_ID=%s\n", h.sess.RunnableID())
for _, e := range os.Environ() {
if strings.HasPrefix(e, "OLLIE_") && !strings.HasPrefix(e, "OLLIE_SESSION_ID=") {
sb.WriteString(e)
sb.WriteByte('\n')
}
}
return sb.String()
}
func (h *sessionHelper) cfgContent() string {
func (h *agentHelper) cfgContent() string {
h.sess.mu.RLock()
defer h.sess.mu.RUnlock()
p := h.sess.Core.Agent().GenParams()
@ -467,7 +537,7 @@ func (h *sessionHelper) cfgContent() string {
return sb.String()
}
func (h *sessionHelper) handleCfg(input string) error {
func (h *agentHelper) handleCfg(input string) error {
p := h.sess.Core.Agent().GenParams()
hasParams := false
var deferredBackend, deferredModel string
@ -489,9 +559,7 @@ func (h *sessionHelper) handleCfg(input string) error {
if h.sess.Core.Agent().IsRunning() {
return fmt.Errorf("cannot rename while agent is running")
}
if err := h.rename(v); err != nil {
return err
}
return fmt.Errorf("rename not supported via agent cfg; use session/ctl rn")
case "agent":
if v == "" {
continue
@ -518,9 +586,7 @@ func (h *sessionHelper) handleCfg(input string) error {
if v == "" {
continue
}
if err := h.sess.Core.SetCWD(v); err != nil {
return err
}
return h.sess.Core.SetCWD(v)
case "maxTokens":
hasParams = true
if v == "" {
@ -643,7 +709,7 @@ func (h *sessionHelper) handleCfg(input string) error {
return nil
}
func (h *sessionHelper) handleCtl(input string) error {
func (h *agentHelper) handleCtl(input string) error {
cmd := strings.Fields(input)
if len(cmd) == 0 {
return fmt.Errorf("empty ctl command")
@ -655,19 +721,7 @@ func (h *sessionHelper) handleCtl(input string) error {
// FIXME: kill currently kills the entire session because we only support
// one agent per session. When multi-agent-per-session lands, kill should
// kill the agent only; session and chat stream persist.
h.kill()
case "rn":
if name := strings.TrimSpace(input[3:]); name != "" {
if err := h.rename(name); err != nil {
h.log.Error("rename: %v", err)
}
}
case "save":
h.sess.mu.RLock()
data := make([]byte, len(h.sess.log))
copy(data, h.sess.log)
h.sess.mu.RUnlock()
return h.saveTranscript(data)
h.sess.Cancel()
case "detach":
if !h.sess.Core.Agent().Detach() {
return fmt.Errorf("no running process to detach")

View File

@ -65,9 +65,10 @@ var Perms = map[string]Perm{
PathTools: {DirMode: 0755, FileMode: 0755},
PathSessionFile: {
Files: map[string]os.FileMode{
"elevate": 0666,
"plan": 0666,
"cwd": 0666,
"ctl": 0666,
"plan": 0666,
"env": 0444,
"prompt": 0666,
"fifo.in": 0222,
"fifo.out": 0444,
@ -82,7 +83,6 @@ var Perms = map[string]Perm{
"ctxsz": 0444,
"models": 0444,
"systemprompt": 0444,
"env": 0444,
"tail": 0555,
"prompt.prev": 0444,
"context": 0444,