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:
parent
5a03499c66
commit
df74c3fa60
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue