toolsrv: use fsedsl for namespace spec

- spec9p.go: fsedsl-based namespace specification
  - ToolsrvCtx with Server, Agent, Proc
  - Handlers as package-level functions
  - Follows fs/spec.go pattern

- server9p.go: simplified to state management only
  - Agent registration with HMAC tokens
  - BuildTree() creates fsedsl tree
  - Removed redundant 9P protocol handling

The 9P protocol serving will use the same pattern as olliesrv
(cmd/olliesrv/server.go), which takes a *fsedsl.Tree and handles
all 9P message types generically.
This commit is contained in:
Levi Neely 2026-08-10 15:52:34 +02:00
parent d37404ebd3
commit 6fc7cf999b
3 changed files with 291 additions and 788 deletions

View File

@ -1,16 +1,4 @@
// server9p.go - 9P server implementation for toolsrv
//
// Namespace:
// /register rdwr: register agent (aname + cwd + sig → token)
// /ctl write: load/unload tool
// /tools read: list loaded tools
// /proc/new rdwr: token + tool + args → result (blocking)
// /proc/new.bg write: token + tool + args → pid (detached)
// /proc/{pid}/out read: output stream
// /proc/{pid}/wait read: blocks until exit, returns code
// /proc/{pid}/stat read: status
// /proc/{pid}/ctl write: signal, dismiss
// /info read: platform info
// server9p.go - State management for 9P toolsrv
package toolsrv
import (
@ -19,19 +7,15 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net"
"path"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"9fans.net/go/plan9"
"ollie/fsedsl"
)
// Server9P implements a 9P server for tool execution.
// Server9P holds the state for the 9P tool server.
// The actual 9P protocol handling is done by the generic 9P server code;
// this struct just manages agents, tools, and execution.
type Server9P struct {
mu sync.RWMutex
@ -123,656 +107,18 @@ func (s *Server9P) computeSig(id, cwd string) string {
return hex.EncodeToString(h.Sum(nil))
}
// Serve starts serving 9P on the given listener.
func (s *Server9P) Serve(ln net.Listener) error {
for {
conn, err := ln.Accept()
if err != nil {
return err
}
go s.serveConn(conn)
}
}
// serveConn handles a single 9P connection.
func (s *Server9P) serveConn(conn net.Conn) {
defer conn.Close()
cs := &connState9P{
srv: s,
fids: make(map[uint32]*fid9P),
}
for {
fcall, err := readFcall(conn)
if err != nil {
if err != io.EOF {
// log error
}
return
}
resp := cs.handle(fcall)
if err := writeFcall(conn, resp); err != nil {
return
}
}
}
// connState9P holds per-connection state.
type connState9P struct {
srv *Server9P
mu sync.Mutex
fids map[uint32]*fid9P
auth bool // connection authenticated
}
// fid9P represents an open file handle.
type fid9P struct {
path string
qid plan9.Qid
opened bool
mode uint8
// For rdwr operations
wrBuf []byte
rdBuf []byte
rdOff int64
}
func (cs *connState9P) handle(fc *plan9.Fcall) *plan9.Fcall {
resp := &plan9.Fcall{Tag: fc.Tag}
switch fc.Type {
case plan9.Tversion:
resp.Type = plan9.Rversion
resp.Msize = fc.Msize
if fc.Msize > 65536 {
resp.Msize = 65536
}
resp.Version = "9P2000"
case plan9.Tauth:
// We use shared secret, not per-connection auth
// Accept any auth request, actual verification happens at /register
resp.Type = plan9.Rauth
resp.Aqid = plan9.Qid{Type: plan9.QTAUTH}
cs.auth = true
case plan9.Tattach:
if !cs.auth && fc.Afid != plan9.NOFID {
// Auth was provided, mark as authenticated
cs.auth = true
}
cs.mu.Lock()
cs.fids[fc.Fid] = &fid9P{
path: "/",
qid: plan9.Qid{Type: plan9.QTDIR, Path: 0},
}
cs.mu.Unlock()
resp.Type = plan9.Rattach
resp.Qid = plan9.Qid{Type: plan9.QTDIR, Path: 0}
case plan9.Twalk:
resp = cs.handleWalk(fc)
case plan9.Topen:
resp = cs.handleOpen(fc)
case plan9.Tread:
resp = cs.handleRead(fc)
case plan9.Twrite:
resp = cs.handleWrite(fc)
case plan9.Tclunk:
cs.mu.Lock()
delete(cs.fids, fc.Fid)
cs.mu.Unlock()
resp.Type = plan9.Rclunk
case plan9.Tstat:
resp = cs.handleStat(fc)
case plan9.Tremove:
resp.Type = plan9.Rerror
resp.Ename = "remove not supported"
case plan9.Twstat:
resp.Type = plan9.Rerror
resp.Ename = "wstat not supported"
default:
resp.Type = plan9.Rerror
resp.Ename = fmt.Sprintf("unknown message type %d", fc.Type)
}
return resp
}
func (cs *connState9P) handleWalk(fc *plan9.Fcall) *plan9.Fcall {
resp := &plan9.Fcall{Tag: fc.Tag}
cs.mu.Lock()
f, ok := cs.fids[fc.Fid]
cs.mu.Unlock()
if !ok {
resp.Type = plan9.Rerror
resp.Ename = "unknown fid"
return resp
}
newPath := f.path
var qids []plan9.Qid
for _, name := range fc.Wname {
if name == ".." {
newPath = path.Dir(newPath)
} else {
newPath = path.Join(newPath, name)
}
qt, qp, err := cs.srv.qidFor(newPath)
if err != nil {
if len(qids) == 0 {
resp.Type = plan9.Rerror
resp.Ename = err.Error()
return resp
}
break
}
qids = append(qids, plan9.Qid{Type: qt, Path: qp})
}
resp.Type = plan9.Rwalk
resp.Wqid = qids
if len(qids) == len(fc.Wname) {
cs.mu.Lock()
if fc.Newfid != fc.Fid {
cs.fids[fc.Newfid] = &fid9P{
path: newPath,
qid: qids[len(qids)-1],
}
} else if len(qids) > 0 {
f.path = newPath
f.qid = qids[len(qids)-1]
}
cs.mu.Unlock()
}
return resp
}
func (cs *connState9P) handleOpen(fc *plan9.Fcall) *plan9.Fcall {
resp := &plan9.Fcall{Tag: fc.Tag}
cs.mu.Lock()
f, ok := cs.fids[fc.Fid]
cs.mu.Unlock()
if !ok {
resp.Type = plan9.Rerror
resp.Ename = "unknown fid"
return resp
}
qt, qp, err := cs.srv.qidFor(f.path)
if err != nil {
resp.Type = plan9.Rerror
resp.Ename = err.Error()
return resp
}
f.opened = true
f.mode = fc.Mode
f.qid = plan9.Qid{Type: qt, Path: qp}
f.wrBuf = nil
f.rdBuf = nil
f.rdOff = 0
resp.Type = plan9.Ropen
resp.Qid = f.qid
resp.Iounit = 8192
return resp
}
func (cs *connState9P) handleRead(fc *plan9.Fcall) *plan9.Fcall {
resp := &plan9.Fcall{Tag: fc.Tag}
cs.mu.Lock()
f, ok := cs.fids[fc.Fid]
cs.mu.Unlock()
if !ok {
resp.Type = plan9.Rerror
resp.Ename = "unknown fid"
return resp
}
if !f.opened {
resp.Type = plan9.Rerror
resp.Ename = "not open"
return resp
}
// For rdwr files, check if we have a result buffer
if f.rdBuf != nil {
data := cs.readFromBuf(f, fc.Offset, fc.Count)
resp.Type = plan9.Rread
resp.Data = data
return resp
}
// Regular read
data, err := cs.srv.readFile(f.path, int64(fc.Offset), int(fc.Count))
if err != nil {
resp.Type = plan9.Rerror
resp.Ename = err.Error()
return resp
}
resp.Type = plan9.Rread
resp.Data = data
return resp
}
func (cs *connState9P) readFromBuf(f *fid9P, offset uint64, count uint32) []byte {
if int64(offset) >= int64(len(f.rdBuf)) {
return nil
}
end := int64(offset) + int64(count)
if end > int64(len(f.rdBuf)) {
end = int64(len(f.rdBuf))
}
return f.rdBuf[offset:end]
}
func (cs *connState9P) handleWrite(fc *plan9.Fcall) *plan9.Fcall {
resp := &plan9.Fcall{Tag: fc.Tag}
cs.mu.Lock()
f, ok := cs.fids[fc.Fid]
cs.mu.Unlock()
if !ok {
resp.Type = plan9.Rerror
resp.Ename = "unknown fid"
return resp
}
if !f.opened {
resp.Type = plan9.Rerror
resp.Ename = "not open"
return resp
}
// Handle rdwr files - buffer writes, execute on read
if isRdwrPath(f.path) {
f.wrBuf = append(f.wrBuf, fc.Data...)
// Check if we have a complete request (ends with newline or is a clunk)
if len(f.wrBuf) > 0 && f.wrBuf[len(f.wrBuf)-1] == '\n' {
result, err := cs.srv.handleRdwr(f.path, string(f.wrBuf))
if err != nil {
f.rdBuf = []byte("error: " + err.Error() + "\n")
} else {
f.rdBuf = []byte(result)
}
f.wrBuf = nil
}
resp.Type = plan9.Rwrite
resp.Count = uint32(len(fc.Data))
return resp
}
// Regular write
n, err := cs.srv.writeFile(f.path, fc.Data)
if err != nil {
resp.Type = plan9.Rerror
resp.Ename = err.Error()
return resp
}
resp.Type = plan9.Rwrite
resp.Count = uint32(n)
return resp
}
func (cs *connState9P) handleStat(fc *plan9.Fcall) *plan9.Fcall {
resp := &plan9.Fcall{Tag: fc.Tag}
cs.mu.Lock()
f, ok := cs.fids[fc.Fid]
cs.mu.Unlock()
if !ok {
resp.Type = plan9.Rerror
resp.Ename = "unknown fid"
return resp
}
dir, err := cs.srv.statFile(f.path)
if err != nil {
resp.Type = plan9.Rerror
resp.Ename = err.Error()
return resp
}
resp.Type = plan9.Rstat
resp.Stat = dir
return resp
}
// --- File system operations ---
func (s *Server9P) qidFor(p string) (uint8, uint64, error) {
switch p {
case "/":
return plan9.QTDIR, 0, nil
case "/register", "/ctl", "/tools", "/info":
return plan9.QTFILE, pathHash(p), nil
case "/proc":
return plan9.QTDIR, pathHash(p), nil
case "/proc/new", "/proc/new.bg":
return plan9.QTFILE, pathHash(p), nil
}
if strings.HasPrefix(p, "/proc/") {
parts := strings.Split(p[6:], "/")
if len(parts) == 1 {
// /proc/{pid}
return plan9.QTDIR, pathHash(p), nil
}
if len(parts) == 2 {
// /proc/{pid}/{file}
switch parts[1] {
case "out", "wait", "stat", "ctl":
return plan9.QTFILE, pathHash(p), nil
}
}
}
return 0, 0, fmt.Errorf("not found: %s", p)
}
func pathHash(p string) uint64 {
var h uint64 = 5381
for _, c := range p {
h = h*33 + uint64(c)
}
return h
}
func isRdwrPath(p string) bool {
return p == "/register" || p == "/proc/new"
}
func (s *Server9P) readFile(p string, offset int64, count int) ([]byte, error) {
var data []byte
switch p {
case "/":
data = []byte("ctl\ninfo\nproc\nregister\ntools\n")
case "/tools":
data = []byte(s.fs.HandleToolsRead())
case "/info":
data = []byte(s.hostInfo())
case "/proc":
var entries []string
entries = append(entries, "new", "new.bg")
for _, pid := range s.fs.ListProcs() {
entries = append(entries, strconv.Itoa(pid))
}
sort.Strings(entries)
data = []byte(strings.Join(entries, "\n") + "\n")
default:
if strings.HasPrefix(p, "/proc/") {
return s.readProcFile(p, offset, count)
}
return nil, fmt.Errorf("cannot read: %s", p)
}
if offset >= int64(len(data)) {
return nil, nil
}
end := offset + int64(count)
if end > int64(len(data)) {
end = int64(len(data))
}
return data[offset:end], nil
}
func (s *Server9P) readProcFile(p string, offset int64, count int) ([]byte, error) {
parts := strings.Split(p[6:], "/") // strip "/proc/"
if len(parts) < 2 {
return nil, fmt.Errorf("invalid proc path")
}
pid, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid pid")
}
proc := s.fs.GetProc(pid)
if proc == nil {
return nil, fmt.Errorf("process not found")
}
var data []byte
switch parts[1] {
case "out":
data = []byte(proc.Output())
case "wait":
exitCode := proc.Wait()
data = []byte(fmt.Sprintf("%d\n", exitCode))
case "stat":
data = []byte(proc.Stat())
default:
return nil, fmt.Errorf("not found: %s", parts[1])
}
if offset >= int64(len(data)) {
return nil, nil
}
end := offset + int64(count)
if end > int64(len(data)) {
end = int64(len(data))
}
return data[offset:end], nil
}
func (s *Server9P) writeFile(p string, data []byte) (int, error) {
input := strings.TrimSpace(string(data))
switch p {
case "/ctl":
if err := s.fs.HandleCtl(input); err != nil {
return 0, err
}
return len(data), nil
case "/tools":
if err := s.fs.HandleToolsWrite(input); err != nil {
return 0, err
}
return len(data), nil
case "/proc/new.bg":
return s.handleProcNewBg(input)
default:
if strings.HasPrefix(p, "/proc/") && strings.HasSuffix(p, "/ctl") {
return s.handleProcCtl(p, input)
}
return 0, fmt.Errorf("cannot write: %s", p)
}
}
func (s *Server9P) handleRdwr(p string, input string) (string, error) {
switch p {
case "/register":
return s.handleRegister(input)
case "/proc/new":
return s.handleProcNew(input)
default:
return "", fmt.Errorf("not rdwr: %s", p)
}
}
func (s *Server9P) handleRegister(input string) (string, error) {
args := parseKV(input)
id := args["aname"]
cwd := args["cwd"]
sig := args["sig"]
if id == "" || cwd == "" || sig == "" {
return "", fmt.Errorf("missing aname, cwd, or sig")
}
token, err := s.RegisterAgent(id, cwd, sig)
if err != nil {
return "", err
}
return token + "\n", nil
}
func (s *Server9P) handleProcNew(input string) (string, error) {
args := parseKV(input)
token := args["token"]
agent, ok := s.GetAgent(token)
if !ok {
return "", fmt.Errorf("invalid token")
}
// Set cwd for this execution
s.fs.SetCWD(agent.CWD)
// Build payload without token
var payload strings.Builder
for k, v := range args {
if k == "token" {
continue
}
payload.WriteString(k)
payload.WriteString("=")
payload.WriteString(v)
payload.WriteString("\n")
}
return s.fs.HandleProcNew(nil, payload.String()) // TODO: pass context
}
func (s *Server9P) handleProcNewBg(input string) (int, error) {
args := parseKV(input)
token := args["token"]
agent, ok := s.GetAgent(token)
if !ok {
return 0, fmt.Errorf("invalid token")
}
// Set cwd for this execution
s.fs.SetCWD(agent.CWD)
// Build payload without token
var payload strings.Builder
for k, v := range args {
if k == "token" {
continue
}
payload.WriteString(k)
payload.WriteString("=")
payload.WriteString(v)
payload.WriteString("\n")
}
result, err := s.fs.HandleProcNewBg(nil, payload.String()) // TODO: pass context
if err != nil {
return 0, err
}
return len(result), nil
}
func (s *Server9P) handleProcCtl(p string, input string) (int, error) {
// Extract pid from path: /proc/{pid}/ctl
parts := strings.Split(p[6:], "/")
if len(parts) < 2 {
return 0, fmt.Errorf("invalid path")
}
pid, err := strconv.Atoi(parts[0])
if err != nil {
return 0, fmt.Errorf("invalid pid")
}
if err := s.fs.HandleProcCtl(pid, input); err != nil {
return 0, err
}
return len(input), nil
}
func (s *Server9P) statFile(p string) ([]byte, error) {
qt, qp, err := s.qidFor(p)
if err != nil {
return nil, err
}
name := path.Base(p)
if p == "/" {
name = "/"
}
mode := plan9.Perm(0644)
if qt == plan9.QTDIR {
mode = plan9.Perm(plan9.DMDIR | 0755)
}
dir := plan9.Dir{
Name: name,
Qid: plan9.Qid{Type: qt, Path: qp},
Mode: mode,
Uid: "toolsrv",
Gid: "toolsrv",
}
data, err := dir.Bytes()
return data, err
}
func (s *Server9P) hostInfo() string {
// 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)
}
// parseKV parses key=value lines.
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
v = strings.ReplaceAll(v, "\\n", "\n")
v = strings.ReplaceAll(v, "\\\\", "\\")
m[k] = v
}
return m
// FS returns the underlying filesystem state.
func (s *Server9P) FS() *FS9P {
return s.fs
}
// --- 9P message I/O ---
func readFcall(r io.Reader) (*plan9.Fcall, error) {
return plan9.ReadFcall(r)
}
func writeFcall(w io.Writer, fc *plan9.Fcall) error {
return plan9.WriteFcall(w, fc)
// BuildTree creates the fsedsl tree for this server.
func (s *Server9P) BuildTree() *fsedsl.Tree {
ctx := ToolsrvCtx{Server: s}
return fsedsl.BuildTree(ToolsrvSpec(), ctx)
}

View File

@ -76,128 +76,22 @@ func TestServer9P_GetAgentNotFound(t *testing.T) {
}
}
func TestServer9P_QidFor(t *testing.T) {
func TestServer9P_BuildTree(t *testing.T) {
secret := []byte("test-secret-key-1234567890123456")
srv := NewServer9P(secret)
tests := []struct {
path string
wantDir bool
wantErr bool
}{
{"/", true, false},
{"/register", false, false},
{"/ctl", false, false},
{"/tools", false, false},
{"/info", false, false},
{"/proc", true, false},
{"/proc/new", false, false},
{"/proc/new.bg", false, false},
{"/proc/123", true, false},
{"/proc/123/out", false, false},
{"/proc/123/wait", false, false},
{"/proc/123/stat", false, false},
{"/proc/123/ctl", false, false},
{"/nonexistent", false, true},
{"/proc/123/nonexistent", false, true},
}
for _, tt := range tests {
qt, _, err := srv.qidFor(tt.path)
if tt.wantErr {
if err == nil {
t.Errorf("qidFor(%q) should error", tt.path)
}
continue
}
if err != nil {
t.Errorf("qidFor(%q) error: %v", tt.path, err)
continue
}
isDir := qt == 0x80 // QTDIR
if isDir != tt.wantDir {
t.Errorf("qidFor(%q) isDir = %v, want %v", tt.path, isDir, tt.wantDir)
}
tree := srv.BuildTree()
if tree == nil {
t.Fatal("BuildTree returned nil")
}
}
func TestParseKV(t *testing.T) {
tests := []struct {
input string
want map[string]string
}{
{
input: "key=value\n",
want: map[string]string{"key": "value"},
},
{
input: "a=1\nb=2\nc=3\n",
want: map[string]string{"a": "1", "b": "2", "c": "3"},
},
{
input: "path=/home/user/project\n",
want: map[string]string{"path": "/home/user/project"},
},
{
input: "multi=line\\none\\ntwo\n",
want: map[string]string{"multi": "line\none\ntwo"},
},
{
input: "empty=\n",
want: map[string]string{"empty": ""},
},
}
func TestServer9P_HostInfo(t *testing.T) {
secret := []byte("test-secret-key-1234567890123456")
srv := NewServer9P(secret)
for _, tt := range tests {
got := parseKV(tt.input)
for k, v := range tt.want {
if got[k] != v {
t.Errorf("parseKV(%q)[%q] = %q, want %q", tt.input, k, got[k], v)
}
}
}
}
func TestComputeRegistrationSig(t *testing.T) {
secret := []byte("test-secret")
sig1 := ComputeRegistrationSig(secret, "agent1", "/path1")
sig2 := ComputeRegistrationSig(secret, "agent1", "/path1")
sig3 := ComputeRegistrationSig(secret, "agent1", "/path2")
sig4 := ComputeRegistrationSig(secret, "agent2", "/path1")
// Same inputs should produce same signature
if sig1 != sig2 {
t.Error("same inputs should produce same signature")
}
// Different cwd should produce different signature
if sig1 == sig3 {
t.Error("different cwd should produce different signature")
}
// Different agent should produce different signature
if sig1 == sig4 {
t.Error("different agent should produce different signature")
}
}
func TestGenerateSecret(t *testing.T) {
s1, err := GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
if len(s1) != 32 {
t.Errorf("secret length = %d, want 32", len(s1))
}
s2, err := GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
// Should be different each time
if string(s1) == string(s2) {
t.Error("GenerateSecret should produce different values")
info := srv.HostInfo()
if info == "" {
t.Fatal("HostInfo returned empty string")
}
}

263
toolsrv/spec9p.go Normal file
View File

@ -0,0 +1,263 @@
// spec9p.go - fsedsl-based namespace specification for toolsrv
package toolsrv
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"ollie/fsedsl"
)
// ToolsrvCtx is the context passed through the fsedsl tree.
type ToolsrvCtx struct {
Server *Server9P
Agent *AgentInfo // set when traversing into agent-specific paths
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.
func ToolsrvSpec() FsNodeDecl {
return DirNode("/",
FileNode("register", 0666,
Doc("Register agent: write aname=<id>\\ncwd=<path>\\nsig=<sig>, read token"),
Request(handleRegister),
),
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 handleRegister(ctx ToolsrvCtx, data []byte) ([]byte, error) {
args := parseKV(string(data))
id := args["aname"]
cwd := args["cwd"]
sig := args["sig"]
if id == "" || cwd == "" || sig == "" {
return nil, fmt.Errorf("missing aname, cwd, or sig")
}
token, err := ctx.Server.RegisterAgent(id, cwd, sig)
if err != nil {
return nil, err
}
return []byte(token + "\n"), nil
}
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) {
args := parseKV(string(data))
token := args["token"]
agent, ok := ctx.Server.GetAgent(token)
if !ok {
return nil, fmt.Errorf("invalid token")
}
// Set cwd for this execution
ctx.Server.fs.SetCWD(agent.CWD)
// Build payload without token
var payload strings.Builder
for k, v := range args {
if k == "token" {
continue
}
payload.WriteString(k)
payload.WriteString("=")
payload.WriteString(v)
payload.WriteString("\n")
}
result, err := ctx.Server.fs.HandleProcNew(context.Background(), payload.String())
if err != nil {
return nil, err
}
return []byte(result), nil
}
func handleProcNewBg(ctx ToolsrvCtx, data []byte) error {
args := parseKV(string(data))
token := args["token"]
agent, ok := ctx.Server.GetAgent(token)
if !ok {
return fmt.Errorf("invalid token")
}
// Set cwd for this execution
ctx.Server.fs.SetCWD(agent.CWD)
// Build payload without token
var payload strings.Builder
for k, v := range args {
if k == "token" {
continue
}
payload.WriteString(k)
payload.WriteString("=")
payload.WriteString(v)
payload.WriteString("\n")
}
_, err := ctx.Server.fs.HandleProcNewBg(context.Background(), payload.String())
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 \\
v = strings.ReplaceAll(v, "\\n", "\n")
v = strings.ReplaceAll(v, "\\\\", "\\")
m[k] = v
}
return m
}