toolsrv: restructure into internal packages, fix process lifecycle

- Move toolsrv logic into internal/{fs,exec,registry} packages to match
  olliesrv's structure. server.go (9P protocol) stays at top level.

- Deduplicate parseKV/parsePayload: remove dead code from old spec9p.go,
  single implementation in internal/fs/parse.go.

- Add structured logging via log/slog (replaces fmt.Fprintf to stderr).

- Standardize qid path hashing to fnv.New64a (matches olliesrv).

- Fix toolsrv process leaks:
  - Add Pdeathsig to local spawn so toolsrv dies when olliesrv exits.
  - Add idle timeout (30s with no connections after first client seen).
  - ProcessKeeper.Dial() kills old process before respawning.
This commit is contained in:
Ollie Agent 2026-08-11 07:21:14 +02:00
parent 5a03499c66
commit df74c3fa60
14 changed files with 758 additions and 789 deletions

View File

@ -85,6 +85,7 @@ func Spawn(ctx context.Context, cwd string, opts ...Option) (*Process, error) {
// Note: secret is NOT passed via env var. It's established on first
// connection via 9P Tauth (socket permissions + SSH handle security).
cmd.Stderr = os.Stderr // Let server errors go to stderr for debugging
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM}
if err := cmd.Start(); err != nil {
cancel()
@ -333,6 +334,10 @@ func (pk *ProcessKeeper) Dial() (*toolsrvclient.Conn, error) {
}
if pk.respawn != nil {
// Kill the old process to prevent orphans.
if pk.proc != nil {
pk.proc.Kill()
}
proc, err := pk.respawn(pk.ctx)
if err != nil {
return nil, fmt.Errorf("respawn: %w", err)

View File

@ -1,17 +0,0 @@
// auth9p.go - Authentication helpers for 9P toolsrv
package main
import (
"crypto/rand"
"encoding/hex"
)
// GenerateSecret generates a random shared secret for toolsrv auth.
// The secret is transmitted over the socket via 9P Tauth.
func GenerateSecret() (string, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return "", err
}
return hex.EncodeToString(secret), nil
}

View File

@ -1,5 +1,5 @@
// exec9p.go - Decoupled tool execution for 9P toolsrv
package main
// Package exec handles tool execution in sandboxed environments.
package exec
import (
"bytes"
@ -21,9 +21,8 @@ import (
"ollie/toolsrv"
)
// ExecConfig contains all context needed for tool execution.
// This decouples execution from Server state.
type ExecConfig struct {
// Config contains all context needed for tool execution.
type Config struct {
CWD string
Env map[string]string
Yolo bool
@ -46,8 +45,7 @@ func WithStreamFunc(ctx context.Context, fn func(string)) context.Context {
}
// ExecuteTool runs a tool script with the given args and returns the result.
// This is the core execution function, decoupled from Server.
func ExecuteTool(ctx context.Context, info toolsrv.ToolInfo, args json.RawMessage, cfg ExecConfig) (json.RawMessage, error) {
func ExecuteTool(ctx context.Context, info toolsrv.ToolInfo, args json.RawMessage, cfg Config) (json.RawMessage, error) {
// Resolve script path
toolPath, err := toolsrv.ResolveTool(info.Name)
if err != nil {
@ -169,7 +167,7 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE
}
// Compute NAMESPACE if not set
if _, ok := envMap["NAMESPACE"]; !ok {
if ns := plan9Namespace(envMap); ns != "" {
if ns := Plan9Namespace(envMap); ns != "" {
envMap["NAMESPACE"] = ns
}
}
@ -333,8 +331,8 @@ func readBypassFrames(ctx context.Context, conn net.Conn, w *bytes.Buffer, strea
}
}
// plan9Namespace computes the Plan 9 namespace directory.
func plan9Namespace(env map[string]string) string {
// Plan9Namespace computes the Plan 9 namespace directory.
func Plan9Namespace(env map[string]string) string {
if ns := env["NAMESPACE"]; ns != "" {
return ns
}

View File

@ -0,0 +1,46 @@
// parse.go - Key=value payload parsing for toolsrv.
package fs
import "strings"
// ParsePayload parses key=value lines into a map.
// Values are unescaped: \n → newline, \\ → backslash.
func ParsePayload(payload string) map[string]string {
args := make(map[string]string)
for _, line := range strings.Split(payload, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
idx := strings.Index(line, "=")
if idx < 0 {
continue
}
key := line[:idx]
value := line[idx+1:]
value = UnescapeValue(value)
args[key] = value
}
return args
}
// UnescapeValue reverses escape sequences: \n → newline, \\ → backslash.
func UnescapeValue(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case 'n':
result = append(result, '\n')
i++
continue
case '\\':
result = append(result, '\\')
i++
continue
}
}
result = append(result, s[i])
}
return string(result)
}

View File

@ -1,27 +1,5 @@
// Package toolsrv provides a 9P filesystem interface to the tool execution server.
//
// Namespace:
//
// /ctl write: load <tool>, unload <tool>
// /tools read: list loaded tools (name\tdesc per line)
// write: tool name to load
// /proc/ dir: running/completed processes
// /proc/new rdwr: write tool+args, blocks, read result (auto-cleanup)
// /proc/new.bg write: write tool+args, returns pid immediately (detached)
// /proc/<pid>/ dir: process directory
// /proc/<pid>/out read: output stream
// /proc/<pid>/wait read: blocks until exit, returns exit code
// /proc/<pid>/stat read: status (running, exited N, runtime)
// /proc/<pid>/ctl write: signal <N>, dismiss
// /info read: host info (JSON)
//
// Tool call payload format: key=value lines, newline delimited.
// Example:
//
// tool=shell
// cmd=ls -la
// timeout=30
package main
// proc.go - Process management and tool operations for toolsrv.
package fs
import (
"bytes"
@ -37,23 +15,24 @@ import (
"syscall"
"time"
"ollie/cmd/toolsrv/internal/exec"
"ollie/cmd/toolsrv/internal/registry"
"ollie/toolsrv"
)
// FS9P implements a 9P-style tool server.
// This is the core logic; the actual 9P serving is handled separately.
type FS9P struct {
// State implements the filesystem state for toolsrv.
type State struct {
mu sync.RWMutex
cwd string
env map[string]string
registry *Registry
registry *registry.Registry
sessID string
yolo bool
// Process management
procMu sync.Mutex
procs map[int]*Proc9P
procs map[int]*Proc
nextPID int
procLimit int
@ -61,8 +40,8 @@ type FS9P struct {
OnToolsChanged func()
}
// Proc9P represents a running or completed process.
type Proc9P struct {
// Proc represents a running or completed process.
type Proc struct {
PID int
Tool string
Args map[string]string
@ -78,71 +57,71 @@ type Proc9P struct {
proc *os.Process // for signaling
}
// NewFS9P creates a new 9P tool server.
func NewFS9P(cwd string) *FS9P {
return &FS9P{
// NewState creates a new filesystem state.
func NewState(cwd string) *State {
return &State{
cwd: cwd,
env: make(map[string]string),
procs: make(map[int]*Proc9P),
procs: make(map[int]*Proc),
nextPID: 1,
procLimit: 32,
}
}
// SetRegistry configures the tool registry and session ID.
func (fs *FS9P) SetRegistry(r *Registry, sessID string) {
fs.mu.Lock()
fs.registry = r
fs.sessID = sessID
fs.mu.Unlock()
func (st *State) SetRegistry(r *registry.Registry, sessID string) {
st.mu.Lock()
st.registry = r
st.sessID = sessID
st.mu.Unlock()
}
// SetYolo enables/disables sandbox bypass.
func (fs *FS9P) SetYolo(yolo bool) {
fs.mu.Lock()
fs.yolo = yolo
fs.mu.Unlock()
func (st *State) SetYolo(yolo bool) {
st.mu.Lock()
st.yolo = yolo
st.mu.Unlock()
}
// CWD returns the current working directory.
func (fs *FS9P) CWD() string {
fs.mu.RLock()
defer fs.mu.RUnlock()
return fs.cwd
func (st *State) CWD() string {
st.mu.RLock()
defer st.mu.RUnlock()
return st.cwd
}
// SetCWD sets the current working directory.
func (fs *FS9P) SetCWD(dir string) {
fs.mu.Lock()
fs.cwd = dir
fs.mu.Unlock()
func (st *State) SetCWD(dir string) {
st.mu.Lock()
st.cwd = dir
st.mu.Unlock()
}
// SetEnv sets an environment variable.
func (fs *FS9P) SetEnv(key, value string) {
fs.mu.Lock()
if fs.env == nil {
fs.env = make(map[string]string)
func (st *State) SetEnv(key, value string) {
st.mu.Lock()
if st.env == nil {
st.env = make(map[string]string)
}
fs.env[key] = value
fs.mu.Unlock()
st.env[key] = value
st.mu.Unlock()
}
// GetEnv returns an environment variable value.
func (fs *FS9P) GetEnv(key string) string {
fs.mu.RLock()
defer fs.mu.RUnlock()
return fs.env[key]
func (st *State) GetEnv(key string) string {
st.mu.RLock()
defer st.mu.RUnlock()
return st.env[key]
}
// --- Tool Registry ---
// ListTools returns loaded tools for the current session.
func (fs *FS9P) ListTools() []toolsrv.ToolInfo {
fs.mu.RLock()
reg := fs.registry
sid := fs.sessID
fs.mu.RUnlock()
func (st *State) ListTools() []toolsrv.ToolInfo {
st.mu.RLock()
reg := st.registry
sid := st.sessID
st.mu.RUnlock()
if reg == nil || sid == "" {
return nil
@ -151,11 +130,11 @@ func (fs *FS9P) ListTools() []toolsrv.ToolInfo {
}
// LoadTool loads a tool by name into the session registry.
func (fs *FS9P) LoadTool(name string) error {
fs.mu.RLock()
reg := fs.registry
sid := fs.sessID
fs.mu.RUnlock()
func (st *State) LoadTool(name string) error {
st.mu.RLock()
reg := st.registry
sid := st.sessID
st.mu.RUnlock()
if reg == nil {
return fmt.Errorf("no tool registry configured")
@ -167,18 +146,18 @@ func (fs *FS9P) LoadTool(name string) error {
if err := reg.Load(sid, name); err != nil {
return err
}
if fs.OnToolsChanged != nil {
fs.OnToolsChanged()
if st.OnToolsChanged != nil {
st.OnToolsChanged()
}
return nil
}
// UnloadTool removes a tool from the session registry.
func (fs *FS9P) UnloadTool(name string) error {
fs.mu.RLock()
reg := fs.registry
sid := fs.sessID
fs.mu.RUnlock()
func (st *State) UnloadTool(name string) error {
st.mu.RLock()
reg := st.registry
sid := st.sessID
st.mu.RUnlock()
if reg == nil {
return fmt.Errorf("no tool registry configured")
@ -190,8 +169,8 @@ func (fs *FS9P) UnloadTool(name string) error {
if err := reg.Unload(sid, name); err != nil {
return err
}
if fs.OnToolsChanged != nil {
fs.OnToolsChanged()
if st.OnToolsChanged != nil {
st.OnToolsChanged()
}
return nil
}
@ -199,44 +178,44 @@ func (fs *FS9P) UnloadTool(name string) error {
// --- Process Management ---
// allocPID allocates a new process ID.
func (fs *FS9P) allocPID() int {
fs.procMu.Lock()
defer fs.procMu.Unlock()
pid := fs.nextPID
fs.nextPID++
func (st *State) allocPID() int {
st.procMu.Lock()
defer st.procMu.Unlock()
pid := st.nextPID
st.nextPID++
return pid
}
// NewProc creates a new process and starts execution.
// If background is false, blocks until completion and returns result.
// If background is true, returns immediately with pid.
func (fs *FS9P) NewProc(ctx context.Context, payload string, background bool) (result string, pid int, err error) {
func (st *State) NewProc(ctx context.Context, payload string, background bool) (result string, pid int, err error) {
// Parse payload
args := parsePayload(payload)
args := ParsePayload(payload)
toolName := args["tool"]
if toolName == "" {
return "", 0, fmt.Errorf("missing 'tool' in payload")
}
// Check proc limit
fs.procMu.Lock()
if len(fs.procs) >= fs.procLimit {
fs.procMu.Unlock()
return "", 0, fmt.Errorf("process limit reached (%d)", fs.procLimit)
st.procMu.Lock()
if len(st.procs) >= st.procLimit {
st.procMu.Unlock()
return "", 0, fmt.Errorf("process limit reached (%d)", st.procLimit)
}
fs.procMu.Unlock()
st.procMu.Unlock()
// Look up tool
fs.mu.RLock()
reg := fs.registry
sid := fs.sessID
cwd := fs.cwd
yolo := fs.yolo
st.mu.RLock()
reg := st.registry
sid := st.sessID
cwd := st.cwd
yolo := st.yolo
envCopy := make(map[string]string)
for k, v := range fs.env {
for k, v := range st.env {
envCopy[k] = v
}
fs.mu.RUnlock()
st.mu.RUnlock()
if reg == nil || sid == "" {
return "", 0, fmt.Errorf("tool registry not configured")
@ -249,8 +228,8 @@ func (fs *FS9P) NewProc(ctx context.Context, payload string, background bool) (r
// Create proc
procCtx, cancel := context.WithCancel(ctx)
proc := &Proc9P{
PID: fs.allocPID(),
proc := &Proc{
PID: st.allocPID(),
Tool: toolName,
Args: args,
StartTime: time.Now(),
@ -258,16 +237,16 @@ func (fs *FS9P) NewProc(ctx context.Context, payload string, background bool) (r
cancel: cancel,
}
fs.procMu.Lock()
fs.procs[proc.PID] = proc
fs.procMu.Unlock()
st.procMu.Lock()
st.procs[proc.PID] = proc
st.procMu.Unlock()
// Execute in goroutine
go func() {
defer close(proc.done)
defer cancel()
out, exitCode := fs.executeTool(procCtx, info, args, cwd, envCopy, yolo)
out, exitCode := st.executeTool(procCtx, info, args, cwd, envCopy, yolo)
proc.mu.Lock()
proc.output.WriteString(out)
@ -290,9 +269,9 @@ func (fs *FS9P) NewProc(ctx context.Context, payload string, background bool) (r
proc.mu.Unlock()
// Auto-cleanup for foreground procs
fs.procMu.Lock()
delete(fs.procs, proc.PID)
fs.procMu.Unlock()
st.procMu.Lock()
delete(st.procs, proc.PID)
st.procMu.Unlock()
if exitCode != 0 {
return result, proc.PID, fmt.Errorf("exit %d", exitCode)
@ -301,17 +280,17 @@ func (fs *FS9P) NewProc(ctx context.Context, payload string, background bool) (r
}
// executeTool runs a tool and returns output + exit code.
func (fs *FS9P) executeTool(ctx context.Context, info toolsrv.ToolInfo, args map[string]string, cwd string, envExtra map[string]string, yolo bool) (string, int) {
func (st *State) executeTool(ctx context.Context, info toolsrv.ToolInfo, args map[string]string, cwd string, envExtra map[string]string, yolo bool) (string, int) {
// Convert args map to JSON for the execution path
jsonArgs := argsToJSON(args)
cfg := ExecConfig{
cfg := exec.Config{
CWD: cwd,
Env: envExtra,
Yolo: yolo,
}
result, err := ExecuteTool(ctx, info, jsonArgs, cfg)
result, err := exec.ExecuteTool(ctx, info, jsonArgs, cfg)
if err != nil {
return fmt.Sprintf("error: %v", err), 1
}
@ -319,37 +298,37 @@ func (fs *FS9P) executeTool(ctx context.Context, info toolsrv.ToolInfo, args map
}
// GetProc returns a process by PID.
func (fs *FS9P) GetProc(pid int) *Proc9P {
fs.procMu.Lock()
defer fs.procMu.Unlock()
return fs.procs[pid]
func (st *State) GetProc(pid int) *Proc {
st.procMu.Lock()
defer st.procMu.Unlock()
return st.procs[pid]
}
// ListProcs returns all process PIDs.
func (fs *FS9P) ListProcs() []int {
fs.procMu.Lock()
defer fs.procMu.Unlock()
pids := make([]int, 0, len(fs.procs))
for pid := range fs.procs {
func (st *State) ListProcs() []int {
st.procMu.Lock()
defer st.procMu.Unlock()
pids := make([]int, 0, len(st.procs))
for pid := range st.procs {
pids = append(pids, pid)
}
return pids
}
// DismissProc removes a process from the list.
func (fs *FS9P) DismissProc(pid int) bool {
fs.procMu.Lock()
defer fs.procMu.Unlock()
if _, ok := fs.procs[pid]; ok {
delete(fs.procs, pid)
func (st *State) DismissProc(pid int) bool {
st.procMu.Lock()
defer st.procMu.Unlock()
if _, ok := st.procs[pid]; ok {
delete(st.procs, pid)
return true
}
return false
}
// SignalProc sends a signal to a process.
func (fs *FS9P) SignalProc(pid int, sig syscall.Signal) error {
proc := fs.GetProc(pid)
func (st *State) SignalProc(pid int, sig syscall.Signal) error {
proc := st.GetProc(pid)
if proc == nil {
return fmt.Errorf("process not found: %d", pid)
}
@ -372,68 +351,147 @@ func (fs *FS9P) SignalProc(pid int, sig syscall.Signal) error {
// --- Host Info ---
// HostInfo9P returns host information.
func (fs *FS9P) HostInfo9P() string {
fs.mu.RLock()
cwd := fs.cwd
fs.mu.RUnlock()
// HostInfo returns host information.
func (st *State) HostInfo() string {
st.mu.RLock()
cwd := st.cwd
st.mu.RUnlock()
return fmt.Sprintf("platform=%s\narch=%s\ncwd=%s\ngit=%v\n",
runtime.GOOS, runtime.GOARCH, cwd, isGitRepo9P(cwd))
runtime.GOOS, runtime.GOARCH, cwd, isGitRepo(cwd))
}
func isGitRepo9P(dir string) bool {
func isGitRepo(dir string) bool {
info, err := os.Stat(dir + "/.git")
return err == nil && info.IsDir()
}
// --- Payload Parsing ---
// --- Request Handlers ---
// parsePayload parses key=value lines into a map.
func parsePayload(payload string) map[string]string {
args := make(map[string]string)
for _, line := range strings.Split(payload, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
// HandleCtl processes ctl commands.
func (st *State) HandleCtl(input string) error {
parts := strings.Fields(input)
if len(parts) == 0 {
return nil
}
switch parts[0] {
case "load":
if len(parts) < 2 {
return fmt.Errorf("load requires tool name")
}
idx := strings.Index(line, "=")
return st.LoadTool(parts[1])
case "unload":
if len(parts) < 2 {
return fmt.Errorf("unload requires tool name")
}
return st.UnloadTool(parts[1])
case "env":
if len(parts) < 2 {
return fmt.Errorf("env requires KEY=VALUE")
}
// Rejoin in case value contains spaces
kv := strings.Join(parts[1:], " ")
idx := strings.Index(kv, "=")
if idx < 0 {
continue
return fmt.Errorf("env requires KEY=VALUE format")
}
key := line[:idx]
value := line[idx+1:]
// Unescape \n and \\ in one pass to handle \\n correctly
value = unescapeValue(value)
args[key] = value
st.SetEnv(kv[:idx], kv[idx+1:])
return nil
case "cwd":
if len(parts) < 2 {
return fmt.Errorf("cwd requires path")
}
st.SetCWD(parts[1])
return nil
default:
return fmt.Errorf("unknown command: %s", parts[0])
}
return args
}
// unescapeValue reverses escapeValue: \\n -> newline, \\\\ -> backslash
func unescapeValue(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case 'n':
result = append(result, '\n')
i++
continue
case '\\':
result = append(result, '\\')
i++
continue
}
}
result = append(result, s[i])
}
return string(result)
// HandleToolsRead returns the tool list as JSON.
func (st *State) HandleToolsRead() string {
tools := st.ListTools()
data, _ := json.Marshal(tools)
return string(data)
}
// HandleToolsWrite loads a tool by name.
func (st *State) HandleToolsWrite(name string) error {
return st.LoadTool(strings.TrimSpace(name))
}
// HandleProcNew is the rdwr handler for /proc/new (blocking).
func (st *State) HandleProcNew(ctx context.Context, payload string) (string, error) {
result, _, err := st.NewProc(ctx, payload, false)
return result, err
}
// HandleProcNewBg is the write handler for /proc/new.bg (background).
func (st *State) HandleProcNewBg(ctx context.Context, payload string) (string, error) {
result, _, err := st.NewProc(ctx, payload, true)
return result, err
}
// HandleProcCtl processes writes to /proc/<pid>/ctl.
func (st *State) HandleProcCtl(pid int, input string) error {
parts := strings.Fields(input)
if len(parts) == 0 {
return nil
}
switch parts[0] {
case "signal":
if len(parts) < 2 {
return fmt.Errorf("signal requires signal number")
}
sig, err := strconv.Atoi(parts[1])
if err != nil {
return fmt.Errorf("invalid signal: %s", parts[1])
}
return st.SignalProc(pid, syscall.Signal(sig))
case "dismiss":
st.DismissProc(pid)
return nil
default:
return fmt.Errorf("unknown command: %s", parts[0])
}
}
// --- Proc Methods ---
// Output returns the current output buffer.
func (p *Proc) Output() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.output.String()
}
// Wait blocks until the process exits, returns exit code.
func (p *Proc) Wait() int {
<-p.done
p.mu.Lock()
defer p.mu.Unlock()
return p.ExitCode
}
// Stat returns a status string.
func (p *Proc) Stat() string {
p.mu.Lock()
defer p.mu.Unlock()
rt := time.Since(p.StartTime)
if p.Exited {
rt = p.EndTime.Sub(p.StartTime)
return fmt.Sprintf("exited %d\nruntime=%v\ntool=%s\n", p.ExitCode, rt, p.Tool)
}
return fmt.Sprintf("running\nruntime=%v\ntool=%s\n", rt, p.Tool)
}
// --- Helpers ---
// argsToJSON converts the args map to JSON for the existing tool path.
func argsToJSON(args map[string]string) []byte {
// Build JSON manually to avoid import cycle
var buf bytes.Buffer
buf.WriteByte('{')
first := true
@ -465,128 +523,6 @@ func escapeJSON(s string) string {
return s
}
// --- Proc9P Methods ---
// Output returns the current output buffer.
func (p *Proc9P) Output() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.output.String()
}
// Wait blocks until the process exits, returns exit code.
func (p *Proc9P) Wait() int {
<-p.done
p.mu.Lock()
defer p.mu.Unlock()
return p.ExitCode
}
// Stat returns a status string.
func (p *Proc9P) Stat() string {
p.mu.Lock()
defer p.mu.Unlock()
runtime := time.Since(p.StartTime)
if p.Exited {
runtime = p.EndTime.Sub(p.StartTime)
return fmt.Sprintf("exited %d\nruntime=%v\ntool=%s\n", p.ExitCode, runtime, p.Tool)
}
return fmt.Sprintf("running\nruntime=%v\ntool=%s\n", runtime, p.Tool)
}
// --- Request Handlers (for integration with 9P server) ---
// HandleCtl processes ctl commands.
func (fs *FS9P) HandleCtl(input string) error {
parts := strings.Fields(input)
if len(parts) == 0 {
return nil
}
switch parts[0] {
case "load":
if len(parts) < 2 {
return fmt.Errorf("load requires tool name")
}
return fs.LoadTool(parts[1])
case "unload":
if len(parts) < 2 {
return fmt.Errorf("unload requires tool name")
}
return fs.UnloadTool(parts[1])
case "env":
if len(parts) < 2 {
return fmt.Errorf("env requires KEY=VALUE")
}
// Rejoin in case value contains spaces
kv := strings.Join(parts[1:], " ")
idx := strings.Index(kv, "=")
if idx < 0 {
return fmt.Errorf("env requires KEY=VALUE format")
}
fs.SetEnv(kv[:idx], kv[idx+1:])
return nil
case "cwd":
if len(parts) < 2 {
return fmt.Errorf("cwd requires path")
}
fs.SetCWD(parts[1])
return nil
default:
return fmt.Errorf("unknown command: %s", parts[0])
}
}
// HandleTools processes reads/writes to /tools.
func (fs *FS9P) HandleToolsRead() string {
tools := fs.ListTools()
data, _ := json.Marshal(tools)
return string(data)
}
// HandleToolsWrite loads a tool by name.
func (fs *FS9P) HandleToolsWrite(name string) error {
return fs.LoadTool(strings.TrimSpace(name))
}
// HandleProcNew is the rdwr handler for /proc/new (blocking).
func (fs *FS9P) HandleProcNew(ctx context.Context, payload string) (string, error) {
result, _, err := fs.NewProc(ctx, payload, false)
return result, err
}
// HandleProcNewBg is the write handler for /proc/new.bg (background).
func (fs *FS9P) HandleProcNewBg(ctx context.Context, payload string) (string, error) {
result, _, err := fs.NewProc(ctx, payload, true)
return result, err
}
// HandleProcCtl processes writes to /proc/<pid>/ctl.
func (fs *FS9P) HandleProcCtl(pid int, input string) error {
parts := strings.Fields(input)
if len(parts) == 0 {
return nil
}
switch parts[0] {
case "signal":
if len(parts) < 2 {
return fmt.Errorf("signal requires signal number")
}
sig, err := strconv.Atoi(parts[1])
if err != nil {
return fmt.Errorf("invalid signal: %s", parts[1])
}
return fs.SignalProc(pid, syscall.Signal(sig))
case "dismiss":
fs.DismissProc(pid)
return nil
default:
return fmt.Errorf("unknown command: %s", parts[0])
}
}
// --- Atomic counters for unique IDs ---
var globalProcCounter atomic.Int64

View File

@ -1,4 +1,4 @@
package main
package fs
import (
"context"
@ -7,181 +7,125 @@ import (
"time"
)
func TestFS9P_Basic(t *testing.T) {
fs := NewFS9P("/tmp")
func TestState_Basic(t *testing.T) {
st := NewState("/tmp")
// Test CWD
if fs.CWD() != "/tmp" {
t.Errorf("CWD() = %q, want /tmp", fs.CWD())
if st.CWD() != "/tmp" {
t.Errorf("CWD() = %q, want /tmp", st.CWD())
}
fs.SetCWD("/home")
if fs.CWD() != "/home" {
t.Errorf("CWD() = %q, want /home", fs.CWD())
st.SetCWD("/home")
if st.CWD() != "/home" {
t.Errorf("CWD() = %q, want /home", st.CWD())
}
// Test Env
fs.SetEnv("FOO", "bar")
if fs.GetEnv("FOO") != "bar" {
t.Errorf("GetEnv(FOO) = %q, want bar", fs.GetEnv("FOO"))
st.SetEnv("FOO", "bar")
if st.GetEnv("FOO") != "bar" {
t.Errorf("GetEnv(FOO) = %q, want bar", st.GetEnv("FOO"))
}
// Test HostInfo
info := fs.HostInfo9P()
info := st.HostInfo()
if !strings.Contains(info, "platform=") {
t.Errorf("HostInfo9P() missing platform")
t.Errorf("HostInfo() missing platform")
}
}
func TestFS9P_ToolsWithoutRegistry(t *testing.T) {
fs := NewFS9P("/tmp")
func TestState_ToolsWithoutRegistry(t *testing.T) {
st := NewState("/tmp")
// Without registry, ListTools should return nil
tools := fs.ListTools()
tools := st.ListTools()
if tools != nil {
t.Errorf("ListTools() without registry = %v, want nil", tools)
}
// Load should fail without registry
err := fs.LoadTool("shell")
err := st.LoadTool("shell")
if err == nil {
t.Error("LoadTool without registry should fail")
}
}
func TestFS9P_HandleCtl(t *testing.T) {
fs := NewFS9P("/tmp")
func TestState_HandleCtl(t *testing.T) {
st := NewState("/tmp")
// Unknown command
err := fs.HandleCtl("unknown")
err := st.HandleCtl("unknown")
if err == nil {
t.Error("HandleCtl(unknown) should fail")
}
// load without name
err = fs.HandleCtl("load")
err = st.HandleCtl("load")
if err == nil {
t.Error("HandleCtl(load) without name should fail")
}
// unload without name
err = fs.HandleCtl("unload")
err = st.HandleCtl("unload")
if err == nil {
t.Error("HandleCtl(unload) without name should fail")
}
}
func TestFS9P_ProcLimit(t *testing.T) {
fs := NewFS9P("/tmp")
fs.procLimit = 2
// Create a fake registry and session ID
reg, err := NewRegistry()
if err != nil {
t.Skip("no tools directory available")
}
fs.SetRegistry(reg, "test-session")
// Load a simple tool if available
if err := reg.Load("test-session", "shell"); err != nil {
t.Skip("shell tool not available")
}
// Proc limit test would require actual tool execution
// For now just verify the limit is set
if fs.procLimit != 2 {
t.Errorf("procLimit = %d, want 2", fs.procLimit)
}
}
func TestFS9P_ParsePayload(t *testing.T) {
tests := []struct {
input string
want map[string]string
}{
{
input: "tool=shell\ncmd=ls -la\n",
want: map[string]string{"tool": "shell", "cmd": "ls -la"},
},
{
input: "key=value with spaces\n",
want: map[string]string{"key": "value with spaces"},
},
{
input: "multi=line\\none\\ntwo\n",
want: map[string]string{"multi": "line\none\ntwo"},
},
{
input: "escaped=back\\\\slash\n",
want: map[string]string{"escaped": "back\\slash"},
},
}
for _, tt := range tests {
got := parsePayload(tt.input)
for k, v := range tt.want {
if got[k] != v {
t.Errorf("parsePayload(%q)[%q] = %q, want %q", tt.input, k, got[k], v)
}
}
}
}
func TestFS9P_ProcLifecycle(t *testing.T) {
fs := NewFS9P("/tmp")
func TestState_ProcLifecycle(t *testing.T) {
st := NewState("/tmp")
// No procs initially
pids := fs.ListProcs()
pids := st.ListProcs()
if len(pids) != 0 {
t.Errorf("ListProcs() = %v, want empty", pids)
}
// GetProc for non-existent
if fs.GetProc(999) != nil {
if st.GetProc(999) != nil {
t.Error("GetProc(999) should return nil")
}
// DismissProc for non-existent
if fs.DismissProc(999) {
if st.DismissProc(999) {
t.Error("DismissProc(999) should return false")
}
// SignalProc for non-existent
if err := fs.SignalProc(999, 15); err == nil {
if err := st.SignalProc(999, 15); err == nil {
t.Error("SignalProc(999) should fail")
}
}
func TestFS9P_HandleProcCtl(t *testing.T) {
fs := NewFS9P("/tmp")
func TestState_HandleProcCtl(t *testing.T) {
st := NewState("/tmp")
// Unknown command
err := fs.HandleProcCtl(1, "unknown")
err := st.HandleProcCtl(1, "unknown")
if err == nil {
t.Error("HandleProcCtl(unknown) should fail")
}
// signal without number
err = fs.HandleProcCtl(1, "signal")
err = st.HandleProcCtl(1, "signal")
if err == nil {
t.Error("HandleProcCtl(signal) without number should fail")
}
// signal with invalid number
err = fs.HandleProcCtl(1, "signal abc")
err = st.HandleProcCtl(1, "signal abc")
if err == nil {
t.Error("HandleProcCtl(signal abc) should fail")
}
// dismiss on non-existent (should succeed silently)
err = fs.HandleProcCtl(999, "dismiss")
err = st.HandleProcCtl(999, "dismiss")
if err != nil {
t.Errorf("HandleProcCtl(dismiss) on non-existent = %v, want nil", err)
}
}
func TestProc9P_Stat(t *testing.T) {
proc := &Proc9P{
func TestProc_Stat(t *testing.T) {
proc := &Proc{
PID: 1,
Tool: "shell",
StartTime: time.Now(),
@ -205,12 +149,45 @@ func TestProc9P_Stat(t *testing.T) {
}
}
func TestFS9P_NewProcWithoutRegistry(t *testing.T) {
fs := NewFS9P("/tmp")
func TestState_NewProcWithoutRegistry(t *testing.T) {
st := NewState("/tmp")
ctx := context.Background()
_, _, err := fs.NewProc(ctx, "tool=shell\ncmd=ls\n", false)
_, _, err := st.NewProc(ctx, "tool=shell\ncmd=ls\n", false)
if err == nil {
t.Error("NewProc without registry should fail")
}
}
func TestParsePayload(t *testing.T) {
tests := []struct {
input string
want map[string]string
}{
{
input: "tool=shell\ncmd=ls -la\n",
want: map[string]string{"tool": "shell", "cmd": "ls -la"},
},
{
input: "key=value with spaces\n",
want: map[string]string{"key": "value with spaces"},
},
{
input: "multi=line\\none\\ntwo\n",
want: map[string]string{"multi": "line\none\ntwo"},
},
{
input: "escaped=back\\\\slash\n",
want: map[string]string{"escaped": "back\\slash"},
},
}
for _, tt := range tests {
got := ParsePayload(tt.input)
for k, v := range tt.want {
if got[k] != v {
t.Errorf("ParsePayload(%q)[%q] = %q, want %q", tt.input, k, got[k], v)
}
}
}
}

View File

@ -0,0 +1,173 @@
// spec.go - fsedsl-based namespace specification for toolsrv.
package fs
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"ollie/fsedsl"
)
// Ctx is the context passed through the fsedsl tree.
type Ctx struct {
Server *Server
Proc *Proc // set when traversing into /proc/{pid}
}
// Type aliases from fsedsl, specialized to Ctx.
type (
FsNodeDecl = fsedsl.FsNodeDecl[Ctx]
Binding = fsedsl.Binding[Ctx]
)
// Specialized fsedsl constructors for Ctx.
var (
DirNode = fsedsl.DirNode[Ctx]
FileNode = fsedsl.FileNode[Ctx]
Each = fsedsl.Each[Ctx]
Doc = fsedsl.Doc[Ctx]
Read = fsedsl.Read[Ctx]
Write = fsedsl.Write[Ctx]
BlockOnce = fsedsl.BlockOnce[Ctx]
Request = fsedsl.Request[Ctx]
)
// Spec returns the fsedsl specification for the toolsrv namespace.
func Spec() FsNodeDecl {
return DirNode("/",
FileNode("ctl", 0222,
Doc("Control: write 'load <tool>' or 'unload <tool>'"),
Write(handleCtlWrite),
),
FileNode("tools", 0666,
Doc("Tools: read to list, write tool name to load"),
Read(handleToolsRead),
Write(handleToolsWrite),
),
FileNode("info", 0444,
Doc("Host info: platform, arch"),
Read(handleInfoRead),
),
DirNode("proc",
FileNode("new", 0666,
Doc("Execute tool: write token + tool + args, read result (blocking)"),
Request(handleProcNew),
),
FileNode("new.bg", 0222,
Doc("Execute tool in background: write token + tool + args, returns pid"),
Write(handleProcNewBg),
),
Each("{pid}", procBindings,
FileNode("out", 0444,
Doc("Process output"),
Read(handleProcOutRead),
),
FileNode("wait", 0444,
Doc("Block until process exits, returns exit code"),
BlockOnce(handleProcWait),
),
FileNode("stat", 0444,
Doc("Process status: running/exited, runtime, tool"),
Read(handleProcStatRead),
),
FileNode("ctl", 0222,
Doc("Process control: write 'signal <N>' or 'dismiss'"),
Write(handleProcCtlWrite),
),
),
),
)
}
// --- Handlers ---
func handleCtlWrite(ctx Ctx, data []byte) error {
return ctx.Server.Fs.HandleCtl(strings.TrimSpace(string(data)))
}
func handleToolsRead(ctx Ctx) ([]byte, error) {
return []byte(ctx.Server.Fs.HandleToolsRead()), nil
}
func handleToolsWrite(ctx Ctx, data []byte) error {
return ctx.Server.Fs.HandleToolsWrite(strings.TrimSpace(string(data)))
}
func handleInfoRead(ctx Ctx) ([]byte, error) {
return []byte(ctx.Server.HostInfo()), nil
}
func handleProcNew(ctx Ctx, data []byte) ([]byte, error) {
result, err := ctx.Server.Fs.HandleProcNew(context.Background(), string(data))
if err != nil {
return nil, err
}
return []byte(result), nil
}
func handleProcNewBg(ctx Ctx, data []byte) error {
_, err := ctx.Server.Fs.HandleProcNewBg(context.Background(), string(data))
return err
}
// procBindings returns bindings for each running process.
func procBindings(ctx Ctx) ([]Binding, error) {
pids := ctx.Server.Fs.ListProcs()
var bindings []Binding
for _, pid := range pids {
proc := ctx.Server.Fs.GetProc(pid)
if proc == nil {
continue
}
p := proc // capture for closure
bindings = append(bindings, Binding{
Name: strconv.Itoa(pid),
Applier: func(c Ctx) Ctx {
c.Proc = p
return c
},
})
}
return bindings, nil
}
func handleProcOutRead(ctx Ctx) ([]byte, error) {
if ctx.Proc == nil {
return nil, fmt.Errorf("no process context")
}
return []byte(ctx.Proc.Output()), nil
}
func handleProcWait(ctx Ctx, cctx context.Context, base string) ([]byte, string, error) {
if ctx.Proc == nil {
return nil, "", fmt.Errorf("no process context")
}
exitCode := ctx.Proc.Wait()
return []byte(fmt.Sprintf("%d\n", exitCode)), "", nil
}
func handleProcStatRead(ctx Ctx) ([]byte, error) {
if ctx.Proc == nil {
return nil, fmt.Errorf("no process context")
}
return []byte(ctx.Proc.Stat()), nil
}
func handleProcCtlWrite(ctx Ctx, data []byte) error {
if ctx.Proc == nil {
return fmt.Errorf("no process context")
}
return ctx.Server.Fs.HandleProcCtl(ctx.Proc.PID, strings.TrimSpace(string(data)))
}
// Mode constants for convenience.
const (
ModeReadOnly os.FileMode = 0444
ModeWriteOnly os.FileMode = 0222
ModeReadWrite os.FileMode = 0666
)

View File

@ -0,0 +1,106 @@
// state.go - Server state management for toolsrv.
package fs
import (
"crypto/rand"
"encoding/hex"
"fmt"
"runtime"
"sync"
"ollie/cmd/toolsrv/internal/registry"
"ollie/fsedsl"
)
// Server holds the state for the toolsrv 9P server.
type Server struct {
mu sync.RWMutex
secret string // set on first auth, verified on subsequent
token string // session token returned after auth
registry *registry.Registry // tool registry
sessID string // session ID for registry scoping
yolo bool // skip sandbox
// Process management
Fs *State
}
// NewServer creates a new toolsrv server.
func NewServer() *Server {
return &Server{
Fs: NewState(""), // cwd set per-agent
}
}
// SetRegistry configures the tool registry and session ID.
func (s *Server) SetRegistry(r *registry.Registry, sessID string) {
s.mu.Lock()
s.registry = r
s.sessID = sessID
s.Fs.SetRegistry(r, sessID)
s.mu.Unlock()
}
// SetYolo enables/disables sandbox bypass.
func (s *Server) SetYolo(yolo bool) {
s.mu.Lock()
s.yolo = yolo
s.Fs.SetYolo(yolo)
s.mu.Unlock()
}
// Authenticate handles secret verification.
// First call sets the secret; subsequent calls must match it.
// Returns (token, nil) on success, ("", error) on failure.
func (s *Server) Authenticate(clientSecret string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.secret == "" {
// First auth - set the secret
s.secret = clientSecret
s.token = randomToken()
return s.token, nil
}
// Subsequent auth - verify secret
if clientSecret != s.secret {
return "", fmt.Errorf("authentication failed")
}
return s.token, nil
}
// Token returns the current session token (empty if not authenticated).
func (s *Server) Token() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.token
}
// HostInfo returns platform info as key=value lines.
func (s *Server) HostInfo() string {
return fmt.Sprintf("platform=%s\narch=%s\n", runtime.GOOS, runtime.GOARCH)
}
// BuildTree creates the fsedsl tree for this server.
func (s *Server) BuildTree() *fsedsl.Tree {
ctx := Ctx{Server: s}
return fsedsl.BuildTree(Spec(), ctx)
}
// GenerateSecret generates a random shared secret for toolsrv auth.
func GenerateSecret() (string, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return "", err
}
return hex.EncodeToString(secret), nil
}
func randomToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}

View File

@ -1,11 +1,12 @@
package main
package fs
import (
"strings"
"testing"
)
func TestServer9P_AuthenticateFirst(t *testing.T) {
srv := NewServer9P()
func TestServer_AuthenticateFirst(t *testing.T) {
srv := NewServer()
secret := "test-secret-0123456789abcdef"
@ -24,8 +25,8 @@ func TestServer9P_AuthenticateFirst(t *testing.T) {
}
}
func TestServer9P_AuthenticateSameSecret(t *testing.T) {
srv := NewServer9P()
func TestServer_AuthenticateSameSecret(t *testing.T) {
srv := NewServer()
secret := "test-secret-0123456789abcdef"
@ -46,8 +47,8 @@ func TestServer9P_AuthenticateSameSecret(t *testing.T) {
}
}
func TestServer9P_AuthenticateWrongSecret(t *testing.T) {
srv := NewServer9P()
func TestServer_AuthenticateWrongSecret(t *testing.T) {
srv := NewServer()
secret := "test-secret-0123456789abcdef"
wrongSecret := "wrong-secret-fedcba9876543210"
@ -65,8 +66,8 @@ func TestServer9P_AuthenticateWrongSecret(t *testing.T) {
}
}
func TestServer9P_TokenBeforeAuth(t *testing.T) {
srv := NewServer9P()
func TestServer_TokenBeforeAuth(t *testing.T) {
srv := NewServer()
// Token should be empty before authentication
if srv.Token() != "" {
@ -74,8 +75,8 @@ func TestServer9P_TokenBeforeAuth(t *testing.T) {
}
}
func TestServer9P_BuildTree(t *testing.T) {
srv := NewServer9P()
func TestServer_BuildTree(t *testing.T) {
srv := NewServer()
tree := srv.BuildTree()
if tree == nil {
@ -83,19 +84,18 @@ func TestServer9P_BuildTree(t *testing.T) {
}
}
func TestServer9P_HostInfo(t *testing.T) {
srv := NewServer9P()
func TestServer_HostInfo(t *testing.T) {
srv := NewServer()
info := srv.HostInfo()
if info == "" {
t.Fatal("HostInfo returned empty string")
}
// Should contain platform info
if !contains(info, "platform=") {
if !strings.Contains(info, "platform=") {
t.Error("HostInfo missing platform=")
}
if !contains(info, "arch=") {
if !strings.Contains(info, "arch=") {
t.Error("HostInfo missing arch=")
}
}
@ -119,16 +119,3 @@ func TestGenerateSecret(t *testing.T) {
t.Error("GenerateSecret returned same secret twice")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr))
}
func containsAt(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -1,5 +1,5 @@
// registry.go - Session-scoped tool registry for toolsrv.
package main
// Package registry provides session-scoped tool registry for toolsrv.
package registry
import (
"fmt"
@ -16,8 +16,8 @@ type Registry struct {
revisions map[string]uint64
}
// NewRegistry creates a new tool registry.
func NewRegistry() (*Registry, error) {
// New creates a new tool registry.
func New() (*Registry, error) {
r := &Registry{
sessions: make(map[string]map[string]toolsrv.ToolInfo),
revisions: make(map[string]uint64),

View File

@ -16,12 +16,17 @@ import (
"context"
"flag"
"fmt"
"log/slog"
"net"
"os"
"os/signal"
"path/filepath"
"sync/atomic"
"syscall"
"time"
"ollie/cmd/toolsrv/internal/fs"
"ollie/cmd/toolsrv/internal/registry"
"ollie/env"
"ollie/fsedsl"
)
@ -32,8 +37,18 @@ var (
sessionID = flag.String("session-id", "", "session ID for tool registry")
yolo = flag.Bool("yolo", false, "skip sandbox enforcement")
noAuth = flag.Bool("no-auth", false, "disable Tauth authentication (for debugging)")
idleTimeout = flag.Duration("idle-timeout", 30*time.Second, "exit after this duration with no connections (0 to disable)")
)
// logger is the toolsrv structured logger.
var logger *slog.Logger
// activeConns tracks the number of active 9P connections.
var activeConns atomic.Int64
// hadConnection is set to true once the first client connects.
var hadConnection atomic.Bool
func main() {
if len(os.Args) < 2 || os.Args[1] != "serve" {
fmt.Fprintln(os.Stderr, "usage: toolsrv serve --cwd <path> --listen <socket> [--yolo]")
@ -46,6 +61,11 @@ func main() {
os.Exit(1)
}
// Set up structured logger
logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
})).With("svc", "toolsrv")
env.EnsureDefaults()
// Prepend our bin dir to PATH so landrun is found (deployed alongside us).
@ -54,24 +74,24 @@ func main() {
os.Setenv("PATH", binDir+":"+os.Getenv("PATH"))
// Create tool registry
toolReg, _ := NewRegistry()
toolReg, _ := registry.New()
sid := *sessionID
if sid == "" {
sid = os.Getenv("OLLIE_SESSION_ID") // fallback to env var
}
// Create 9P server (secret is established via Tauth, not env var)
srv := NewServer9P()
// Create server state
srv := fs.NewServer()
srv.SetYolo(*yolo)
if toolReg != nil && sid != "" {
srv.SetRegistry(toolReg, sid)
}
// Build the filesystem tree
ctx := ToolsrvCtx{Server: srv}
tree := fsedsl.BuildTree(ToolsrvSpec(), ctx)
ctx := fs.Ctx{Server: srv}
tree := fsedsl.BuildTree(fs.Spec(), ctx)
if tree == nil {
fmt.Fprintln(os.Stderr, "error: failed to build filesystem tree")
logger.Error("failed to build filesystem tree")
os.Exit(1)
}
@ -85,19 +105,24 @@ func main() {
cancel()
}()
// Start idle timeout monitor
if *idleTimeout > 0 {
go idleMonitor(runCtx, cancel, *idleTimeout)
}
// Remove stale socket if it exists
os.Remove(*listenPath)
// Listen on Unix socket
ln, err := net.Listen("unix", *listenPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: listen: %v\n", err)
logger.Error("listen failed", "err", err)
os.Exit(1)
}
defer ln.Close()
defer os.Remove(*listenPath)
fmt.Fprintf(os.Stderr, "toolsrv: listening on %s\n", *listenPath)
logger.Info("listening on " + *listenPath)
// Accept connections
go func() {
@ -112,10 +137,65 @@ func main() {
case <-runCtx.Done():
return
default:
fmt.Fprintf(os.Stderr, "accept error: %v\n", err)
logger.Warn("accept error", "err", err)
continue
}
}
go serve9P(runCtx, conn, tree, srv, *noAuth)
activeConns.Add(1)
hadConnection.Store(true)
go func() {
defer activeConns.Add(-1)
serve9P(runCtx, conn, tree, srv, *noAuth)
}()
}
}
// idleMonitor exits the process if there are no active connections for the
// given duration. It waits for the first connection before monitoring (with
// a startup grace period of 60s).
func idleMonitor(ctx context.Context, cancel context.CancelFunc, timeout time.Duration) {
const startupGrace = 60 * time.Second
// Wait for first connection (or startup grace period)
deadline := time.After(startupGrace)
for {
select {
case <-ctx.Done():
return
case <-deadline:
if !hadConnection.Load() {
logger.Info("no connections within startup grace period, exiting")
cancel()
return
}
case <-time.After(1 * time.Second):
if hadConnection.Load() {
goto monitor
}
}
}
monitor:
// Monitor for idle (zero connections after having had at least one)
idleSince := time.Time{}
for {
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
if activeConns.Load() > 0 {
idleSince = time.Time{}
continue
}
// No active connections
if idleSince.IsZero() {
idleSince = time.Now()
logger.Info("all connections closed, idle timer started", "timeout", timeout)
} else if time.Since(idleSince) >= timeout {
logger.Info("idle timeout reached, exiting")
cancel()
return
}
}
}
}

View File

@ -4,11 +4,12 @@ package main
import (
"context"
"fmt"
"hash/fnv"
"io"
"net"
"os"
"sync"
"ollie/cmd/toolsrv/internal/fs"
"ollie/fsedsl"
"9fans.net/go/plan9"
@ -41,12 +42,12 @@ type connState struct {
authFids map[uint32]*authFid
ctx context.Context
uname string
srv *Server9P
srv *fs.Server
noAuth bool // skip authentication (for debugging)
}
// serve9P handles a single 9P connection using the fsedsl tree.
func serve9P(ctx context.Context, conn net.Conn, tree *fsedsl.Tree, srv *Server9P, noAuth bool) {
func serve9P(ctx context.Context, conn net.Conn, tree *fsedsl.Tree, srv *fs.Server, noAuth bool) {
defer conn.Close()
cs := &connState{
@ -67,14 +68,14 @@ func serve9P(ctx context.Context, conn net.Conn, tree *fsedsl.Tree, srv *Server9
fc, err := plan9.ReadFcall(conn)
if err != nil {
if err != io.EOF {
fmt.Fprintf(os.Stderr, "toolsrv: read error: %v\n", err)
logger.Warn("read error", "err", err)
}
return
}
resp := handleFcall(cs, fc, tree)
if err := plan9.WriteFcall(conn, resp); err != nil {
fmt.Fprintf(os.Stderr, "toolsrv: write error: %v\n", err)
logger.Warn("write error", "err", err)
return
}
}
@ -509,9 +510,7 @@ func handleStat(cs *connState, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall
}
func pathHash(p string) uint64 {
var h uint64 = 5381
for _, c := range p {
h = h*33 + uint64(c)
}
return h
h := fnv.New64a()
h.Write([]byte(p))
return h.Sum64()
}

View File

@ -1,101 +0,0 @@
// server9p.go - State management for 9P toolsrv
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"runtime"
"sync"
"ollie/fsedsl"
)
// Server9P holds the state for the 9P tool server.
type Server9P struct {
mu sync.RWMutex
secret string // set on first auth, verified on subsequent
token string // session token returned after auth
registry *Registry // tool registry
sessID string // session ID for registry scoping
yolo bool // skip sandbox
// Process management
fs *FS9P
}
// NewServer9P creates a new 9P tool server.
func NewServer9P() *Server9P {
return &Server9P{
fs: NewFS9P(""), // cwd set per-agent
}
}
// SetRegistry configures the tool registry and session ID.
func (s *Server9P) SetRegistry(r *Registry, sessID string) {
s.mu.Lock()
s.registry = r
s.sessID = sessID
s.fs.SetRegistry(r, sessID)
s.mu.Unlock()
}
// SetYolo enables/disables sandbox bypass.
func (s *Server9P) SetYolo(yolo bool) {
s.mu.Lock()
s.yolo = yolo
s.fs.SetYolo(yolo)
s.mu.Unlock()
}
// Authenticate handles secret verification.
// First call sets the secret; subsequent calls must match it.
// Returns (token, nil) on success, ("", error) on failure.
func (s *Server9P) Authenticate(clientSecret string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.secret == "" {
// First auth - set the secret
s.secret = clientSecret
s.token = randomToken()
return s.token, nil
}
// Subsequent auth - verify secret
if clientSecret != s.secret {
return "", fmt.Errorf("authentication failed")
}
return s.token, nil
}
// Token returns the current session token (empty if not authenticated).
func (s *Server9P) Token() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.token
}
// HostInfo returns platform info as key=value lines.
func (s *Server9P) HostInfo() string {
return fmt.Sprintf("platform=%s\narch=%s\n", runtime.GOOS, runtime.GOARCH)
}
// FS returns the underlying filesystem state.
func (s *Server9P) FS() *FS9P {
return s.fs
}
// BuildTree creates the fsedsl tree for this server.
func (s *Server9P) BuildTree() *fsedsl.Tree {
ctx := ToolsrvCtx{Server: s}
return fsedsl.BuildTree(ToolsrvSpec(), ctx)
}
func randomToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}

View File

@ -1,220 +0,0 @@
// spec9p.go - fsedsl-based namespace specification for toolsrv
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"ollie/fsedsl"
)
// ToolsrvCtx is the context passed through the fsedsl tree.
type ToolsrvCtx struct {
Server *Server9P
Proc *Proc9P // set when traversing into /proc/{pid}
}
// Type aliases from fsedsl, specialized to ToolsrvCtx.
type (
FsNodeDecl = fsedsl.FsNodeDecl[ToolsrvCtx]
Binding = fsedsl.Binding[ToolsrvCtx]
)
// Specialized fsedsl constructors for ToolsrvCtx.
var (
DirNode = fsedsl.DirNode[ToolsrvCtx]
FileNode = fsedsl.FileNode[ToolsrvCtx]
Each = fsedsl.Each[ToolsrvCtx]
Doc = fsedsl.Doc[ToolsrvCtx]
Read = fsedsl.Read[ToolsrvCtx]
Write = fsedsl.Write[ToolsrvCtx]
BlockOnce = fsedsl.BlockOnce[ToolsrvCtx]
Request = fsedsl.Request[ToolsrvCtx]
)
// ToolsrvSpec returns the fsedsl specification for the toolsrv namespace.
// Authentication is handled via 9P Tauth, not a /register file.
func ToolsrvSpec() FsNodeDecl {
return DirNode("/",
FileNode("ctl", 0222,
Doc("Control: write 'load <tool>' or 'unload <tool>'"),
Write(handleCtlWrite),
),
FileNode("tools", 0666,
Doc("Tools: read to list, write tool name to load"),
Read(handleToolsRead),
Write(handleToolsWrite),
),
FileNode("info", 0444,
Doc("Host info: platform, arch"),
Read(handleInfoRead),
),
DirNode("proc",
FileNode("new", 0666,
Doc("Execute tool: write token + tool + args, read result (blocking)"),
Request(handleProcNew),
),
FileNode("new.bg", 0222,
Doc("Execute tool in background: write token + tool + args, returns pid"),
Write(handleProcNewBg),
),
Each("{pid}", procBindings,
FileNode("out", 0444,
Doc("Process output"),
Read(handleProcOutRead),
),
FileNode("wait", 0444,
Doc("Block until process exits, returns exit code"),
BlockOnce(handleProcWait),
),
FileNode("stat", 0444,
Doc("Process status: running/exited, runtime, tool"),
Read(handleProcStatRead),
),
FileNode("ctl", 0222,
Doc("Process control: write 'signal <N>' or 'dismiss'"),
Write(handleProcCtlWrite),
),
),
),
)
}
// --- Handlers ---
func handleCtlWrite(ctx ToolsrvCtx, data []byte) error {
return ctx.Server.fs.HandleCtl(strings.TrimSpace(string(data)))
}
func handleToolsRead(ctx ToolsrvCtx) ([]byte, error) {
return []byte(ctx.Server.fs.HandleToolsRead()), nil
}
func handleToolsWrite(ctx ToolsrvCtx, data []byte) error {
return ctx.Server.fs.HandleToolsWrite(strings.TrimSpace(string(data)))
}
func handleInfoRead(ctx ToolsrvCtx) ([]byte, error) {
return []byte(ctx.Server.HostInfo()), nil
}
func handleProcNew(ctx ToolsrvCtx, data []byte) ([]byte, error) {
// Pass raw payload through - fs9p.go's parsePayload will handle escaping.
// Token verification is done at connection level via Tauth.
result, err := ctx.Server.fs.HandleProcNew(context.Background(), string(data))
if err != nil {
return nil, err
}
return []byte(result), nil
}
func handleProcNewBg(ctx ToolsrvCtx, data []byte) error {
// Pass raw payload through - fs9p.go's parsePayload will handle escaping.
// Token verification is done at connection level via Tauth.
_, err := ctx.Server.fs.HandleProcNewBg(context.Background(), string(data))
return err
}
// procBindings returns bindings for each running process.
func procBindings(ctx ToolsrvCtx) ([]Binding, error) {
pids := ctx.Server.fs.ListProcs()
var bindings []Binding
for _, pid := range pids {
proc := ctx.Server.fs.GetProc(pid)
if proc == nil {
continue
}
p := proc // capture for closure
bindings = append(bindings, Binding{
Name: strconv.Itoa(pid),
Applier: func(c ToolsrvCtx) ToolsrvCtx {
c.Proc = p
return c
},
})
}
return bindings, nil
}
func handleProcOutRead(ctx ToolsrvCtx) ([]byte, error) {
if ctx.Proc == nil {
return nil, fmt.Errorf("no process context")
}
return []byte(ctx.Proc.Output()), nil
}
func handleProcWait(ctx ToolsrvCtx, cctx context.Context, base string) ([]byte, string, error) {
if ctx.Proc == nil {
return nil, "", fmt.Errorf("no process context")
}
exitCode := ctx.Proc.Wait()
return []byte(fmt.Sprintf("%d\n", exitCode)), "", nil
}
func handleProcStatRead(ctx ToolsrvCtx) ([]byte, error) {
if ctx.Proc == nil {
return nil, fmt.Errorf("no process context")
}
return []byte(ctx.Proc.Stat()), nil
}
func handleProcCtlWrite(ctx ToolsrvCtx, data []byte) error {
if ctx.Proc == nil {
return fmt.Errorf("no process context")
}
return ctx.Server.fs.HandleProcCtl(ctx.Proc.PID, strings.TrimSpace(string(data)))
}
// Mode constants for convenience
const (
ModeReadOnly os.FileMode = 0444
ModeWriteOnly os.FileMode = 0222
ModeReadWrite os.FileMode = 0666
)
// parseKV parses key=value lines into a map.
func parseKV(input string) map[string]string {
m := make(map[string]string)
for _, line := range strings.Split(input, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
idx := strings.Index(line, "=")
if idx < 0 {
continue
}
k := line[:idx]
v := line[idx+1:]
// Unescape \n and \\ in one pass
v = unescapeKV(v)
m[k] = v
}
return m
}
// unescapeKV reverses escape sequences: \n -> newline, \\ -> backslash
func unescapeKV(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case 'n':
result = append(result, '\n')
i++
continue
case '\\':
result = append(result, '\\')
i++
continue
}
}
result = append(result, s[i])
}
return string(result)
}