Merge branch 'toolsrv-9p': 9P protocol and package restructure

Major changes:
- toolsrv now uses 9P protocol instead of JSON-RPC
- Clean package split: toolsrv/ (client SDK) vs cmd/toolsrv/ (server)
- tool_load is now a built-in tool (not sandbox-restricted)
- New toolclient package for process spawning and lifecycle
- Remote execution via SSH socket forwarding with Tauth authentication

21 commits from toolsrv-9p branch.
This commit is contained in:
Levi Neely 2026-08-10 21:05:32 +02:00
commit 5a03499c66
111 changed files with 3943 additions and 3194 deletions

View File

@ -1,93 +0,0 @@
// ollie-remote — tool execution server.
//
// Accepts JSON-RPC 2.0 connections. Executes tool calls in a sandboxed
// environment. Used both locally (spawned by olliesrv) and remotely
// (deployed via SSH bootstrap).
//
// Usage:
//
// ollie-remote serve --cwd /path/to/project [--listen /path/to/sock] [--yolo]
//
// When --listen is provided, accepts connections on a Unix socket.
// Otherwise, serves a single session over stdin/stdout (bootstrap mode).
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"ollie/env"
"ollie/toolsrv"
)
var (
cwd = flag.String("cwd", ".", "working directory for execution")
listenPath = flag.String("listen", "", "Unix socket path to listen on (default: stdio mode)")
yolo = flag.Bool("yolo", false, "skip sandbox enforcement")
)
func main() {
if len(os.Args) < 2 || os.Args[1] != "serve" {
fmt.Fprintln(os.Stderr, "usage: ollie-remote serve --cwd <path> [--listen <socket>]")
os.Exit(1)
}
flag.CommandLine.Parse(os.Args[2:])
env.EnsureDefaults()
// Prepend our bin dir to PATH so landrun is found (deployed alongside us).
home, _ := os.UserHomeDir()
binDir := filepath.Join(home, ".config", "ollie", "bin")
os.Setenv("PATH", binDir+":"+os.Getenv("PATH"))
toolReg, _ := toolsrv.NewRegistry()
sessionID := os.Getenv("OLLIE_SESSION_ID")
var opts []toolsrv.Option
if *yolo {
opts = append(opts, toolsrv.WithYolo())
}
if toolReg != nil && sessionID != "" {
opts = append(opts, toolsrv.WithToolRegistry(toolReg, sessionID))
}
server := toolsrv.New(*cwd)
for _, o := range opts {
o(server)
}
// Wire up environment propagation for late-arriving session IDs.
server.OnEnvSet = func(key, value string) {
switch key {
case "OLLIE_SESSION_ID":
os.Setenv("OLLIE_SESSION_ID", value)
if toolReg != nil {
server.SetToolRegistry(toolReg, value)
}
case "OLLIE_UNAME":
os.Setenv("OLLIE_UNAME", value)
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
cancel()
}()
if *listenPath != "" {
if err := server.ServeSocket(ctx, *listenPath); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
} else {
server.ServeRPC(ctx, os.Stdin, os.Stdout)
}
}

View File

@ -5,7 +5,7 @@ import (
"sync"
"github.com/godbus/dbus/v5"
"ollie/bypass"
"ollie/cmd/olliesrv/internal/bypass"
)
// bypassNotifier handles desktop notifications for bypass requests

View File

@ -9,7 +9,7 @@ import (
"sync/atomic"
"syscall"
"ollie/backend"
"ollie/cmd/olliesrv/internal/backend"
olog "ollie/log"
"ollie/paths"
"ollie/toolsrv"
@ -387,7 +387,7 @@ const (
// SetSessionEnv injects session env vars into the execute server.
func (ag *Agent) SetSessionEnv(sessionID string) {
if ag.runtime.ToolServer == nil {
if ag.runtime == nil || ag.runtime.ToolServer == nil {
return
}
ag.runtime.ToolServer.SetEnv("OLLIE_SESSION_ID", sessionID)

View File

@ -4,7 +4,7 @@ import (
"encoding/json"
"io"
"ollie/backend"
"ollie/cmd/olliesrv/internal/backend"
)
// Prompt holds the agent prompt — a list of file paths to read and concatenate.

View File

@ -1,52 +1,12 @@
package agent
import (
"encoding/json"
"io"
"strings"
"testing"
"ollie/toolsrv"
)
// mockToolServer creates a Conn backed by a goroutine that responds to
// list_tools with the given ToolInfo slice. Simulates the ollie-remote RPC.
func mockToolServer(t *testing.T, infos []toolsrv.ToolInfo) *toolsrv.Conn {
t.Helper()
cr, sw := io.Pipe() // client reads, server writes
sr, cw := io.Pipe() // server reads, client writes
go func() {
dec := json.NewDecoder(sr)
enc := json.NewEncoder(sw)
for {
var req struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
if err := dec.Decode(&req); err != nil {
return
}
switch req.Method {
case "list_tools":
result, _ := json.Marshal(infos)
enc.Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": json.RawMessage(result)})
default:
enc.Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": true})
}
}
}()
rwc := struct {
io.Reader
io.Writer
io.Closer
}{cr, cw, cw}
return toolsrv.NewConn(rwc, nil)
}
func TestRefreshToolListing(t *testing.T) {
infos := []toolsrv.ToolInfo{
{
@ -56,20 +16,17 @@ func TestRefreshToolListing(t *testing.T) {
},
}
conn := mockToolServer(t, infos)
defer conn.Close()
p := &Preamble{}
p.Set(SectionSystem, "# System")
p.Set(SectionEnv, "# Env")
p.Set(SectionAgent, "# Agent")
p.Set(SectionTools, "# Tools\n\n## old_tool\n\nOld description\n\n")
ag := &Agent{runtime: &Runtime{Preamble: p, ToolServer: conn}}
// Test renderTools directly since refreshToolListing depends on ToolServer
rendered := renderTools(infos)
p.Set(SectionTools, rendered)
ag.refreshToolListing()
preamble := ag.runtime.PreambleString()
preamble := p.String()
if !strings.Contains(preamble, "## new_tool") {
t.Error("should contain new tool")

View File

@ -3,7 +3,7 @@ package agent
import (
"strings"
"ollie/backend"
"ollie/cmd/olliesrv/internal/backend"
)
// auditTruncate trims s to 200 runes for log output.

View File

@ -7,7 +7,7 @@ import (
"os"
"strings"
"ollie/backend"
"ollie/cmd/olliesrv/internal/backend"
"ollie/paths"
"gopkg.in/yaml.v3"

View File

@ -10,7 +10,7 @@ import (
"sync"
"time"
"ollie/backend"
"ollie/cmd/olliesrv/internal/backend"
"ollie/paths"
"ollie/toolsrv"
)
@ -481,26 +481,34 @@ func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.
var result string
var resultBlocks []backend.ContentBlock
var isErr bool
streamed := false
// Built-in: tool_load - must be handled specially because tools run inside
// toolsrv and can't load other tools into themselves.
if call.Name == "tool_load" {
var args struct {
Name string `json:"name"`
}
if err := json.Unmarshal(call.Arguments, &args); err != nil {
result = "error: " + err.Error()
isErr = true
} else if args.Name == "" {
result = "error: name is required"
isErr = true
} else if rt.ToolServer == nil {
result = "error: no tool server"
isErr = true
} else if err := rt.ToolServer.LoadTool(args.Name); err != nil {
result = "error: " + err.Error()
isErr = true
} else {
result = "loaded: " + args.Name
}
emit(ctx, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, IsError: isErr}, false
}
if rt.Exec != nil {
streamBytes := 0
streamCtx := toolsrv.WithOutputStream(ctx, func(data string) {
if streamBytes >= defaultToolResultMaxBytes {
return
}
if !streamed {
emit(ctx, Event{Role: "tool", Name: call.Name, OutputFormat: toolOutputFormat(rt, call.Name)})
streamed = true
}
streamBytes += len(data)
if streamBytes > defaultToolResultMaxBytes {
excess := streamBytes - defaultToolResultMaxBytes
data = data[:len(data)-excess]
}
emit(ctx, Event{Role: "tool", Name: call.Name, Content: data, OutputFormat: toolOutputFormat(rt, call.Name)})
})
out, blocks, err := rt.Exec(streamCtx, call.Name, call.Arguments)
out, blocks, err := rt.Exec(ctx, call.Name, call.Arguments)
if err != nil {
isErr = true
if ctx.Err() != nil {
@ -555,14 +563,7 @@ func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.
})
}
if !streamed {
emit(ctx, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
} else {
if suffix != "" {
emit(ctx, Event{Role: "tool", Name: call.Name, Content: suffix, OutputFormat: toolOutputFormat(rt, call.Name)})
}
emit(ctx, Event{Role: "tool", Name: call.Name, OutputFormat: toolOutputFormat(rt, call.Name)})
}
emit(ctx, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
tier := MemoryHot
if !isErr {

View File

@ -8,8 +8,8 @@ import (
"strings"
"time"
"ollie/backend"
"ollie/prompts"
"ollie/cmd/olliesrv/internal/backend"
"ollie/cmd/olliesrv/internal/prompts"
"ollie/toolsrv"
)

View File

@ -5,7 +5,7 @@ import (
"fmt"
"strings"
"ollie/backend"
"ollie/cmd/olliesrv/internal/backend"
)
// parseTextToolCalls extracts tool calls from assistant text when the model

View File

@ -9,7 +9,7 @@ import (
"strconv"
"strings"
"ollie/backend"
"ollie/cmd/olliesrv/internal/backend"
"ollie/toolsrv"
)

View File

@ -12,7 +12,7 @@ import (
"testing"
"time"
"ollie/backend"
"ollie/cmd/olliesrv/internal/backend"
)
func mustNewOpenAI(t *testing.T, name, baseURL, apiKey string) *backend.OpenAIBackend {

View File

@ -3,8 +3,8 @@ package fs
import (
"sync"
"ollie/agent"
"ollie/backend"
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/backend"
"ollie/format"
)

View File

@ -5,7 +5,7 @@ import (
"strings"
"sync"
"ollie/backend"
"ollie/cmd/olliesrv/internal/backend"
)
// ModelCache caches the model list from all backends.

View File

@ -8,9 +8,9 @@ import (
"strconv"
"strings"
"ollie/agent"
"ollie/cmd/olliesrv/internal/agent"
"ollie/paths"
"ollie/session"
"ollie/cmd/olliesrv/internal/session"
)
func requestAgentNew(ctx HandlerCtx, data []byte) ([]byte, error) {
@ -412,6 +412,38 @@ func readAgentStats(ctx HandlerCtx) ([]byte, error) {
return []byte(s), nil
}
func readAgentTools(ctx HandlerCtx) ([]byte, error) {
ts := ctx.Agent.ToolServer()
if ts == nil {
return []byte("(no tool server)\n"), nil
}
loaded, err := ts.ListTools()
if err != nil {
return nil, err
}
var sb strings.Builder
for _, ti := range loaded {
if ti.Description != "" {
fmt.Fprintf(&sb, "%-20s %s\n", ti.Name, ti.Description)
} else {
sb.WriteString(ti.Name + "\n")
}
}
return []byte(sb.String()), nil
}
func writeAgentTools(ctx HandlerCtx, data []byte) error {
name := strings.TrimSpace(string(data))
if name == "" {
return nil
}
ts := ctx.Agent.ToolServer()
if ts == nil {
return fmt.Errorf("no tool server")
}
return ts.LoadTool(name)
}
func readAgentName(ctx HandlerCtx) ([]byte, error) {
return []byte(ctx.Agent.Name() + "\n"), nil
}

View File

@ -8,11 +8,11 @@ import (
"strings"
"sync"
"ollie/agent"
"ollie/backend"
"ollie/bypass"
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/backend"
"ollie/cmd/olliesrv/internal/bypass"
"ollie/fsedsl"
"ollie/session"
"ollie/cmd/olliesrv/internal/session"
"ollie/toolsrv"
)

View File

@ -5,8 +5,8 @@ import (
"os"
"strings"
"ollie/bypass"
"ollie/session"
"ollie/cmd/olliesrv/internal/bypass"
"ollie/cmd/olliesrv/internal/session"
)
func readSessionIdx(_ HandlerCtx) ([]byte, error) {

View File

@ -3,7 +3,7 @@ package fs
import (
"sync"
"ollie/session"
"ollie/cmd/olliesrv/internal/session"
)
// RootState holds the root-level state for the 9P filesystem.

View File

@ -4,10 +4,10 @@ import (
"context"
"os/user"
"ollie/bypass"
"ollie/cmd/olliesrv/internal/bypass"
"ollie/fsedsl"
olog "ollie/log"
"ollie/session"
"ollie/cmd/olliesrv/internal/session"
)
// Config carries all runtime dependencies for the filesystem tree.

View File

@ -5,7 +5,7 @@ import (
"strings"
"testing"
"ollie/backend"
"ollie/cmd/olliesrv/internal/backend"
"ollie/format"
)

View File

@ -6,8 +6,8 @@ import (
"sync"
"time"
"ollie/agent"
"ollie/session"
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/session"
"ollie/toolsrv"
)

View File

@ -7,8 +7,8 @@ package fs
import (
"context"
"ollie/agent"
"ollie/bypass"
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/bypass"
"ollie/fsedsl"
olog "ollie/log"
)
@ -273,6 +273,12 @@ FileNode("generate", 0666,
Read(readAgentStats),
),
FileNode("tools", 0666,
Doc("Loaded tools. Read: list. Write: tool name to load."),
Read(readAgentTools),
Write(writeAgentTools),
),
FileNode("name", 0666,
Doc("Agent display name. Read: current name. Write: rename agent."),
Read(readAgentName),

View File

@ -9,8 +9,8 @@ import (
"strings"
"sync"
"ollie/agent"
"ollie/backend"
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/backend"
)
// --- Persistence ---

View File

@ -17,8 +17,8 @@ import (
"sync"
"time"
"ollie/agent"
"ollie/backend"
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/backend"
olog "ollie/log"
"ollie/paths"
)
@ -304,6 +304,7 @@ func CreateEmpty(name, remote string) (*Session, error) {
Ctx: ctx,
CWD: cwd,
RemoteTarget: remote,
SessionID: sessID,
Yolo: pkgYolo,
})
if err != nil {
@ -376,6 +377,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) {
Ctx: sess.Ctx,
CWD: p.CWD,
RemoteTarget: remote,
SessionID: sessID,
Yolo: pkgYolo,
ReuseFrom: reuseFrom,
})

View File

@ -2,7 +2,6 @@ package session
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
@ -10,7 +9,8 @@ import (
"sync"
"time"
"ollie/agent"
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/toolclient"
olog "ollie/log"
"ollie/paths"
"ollie/toolsrv"
@ -27,8 +27,8 @@ type Session struct {
Cancel context.CancelFunc
// Tool server lifecycle (set during setup/teardown, not raced)
Proc *toolsrv.Process
Keeper *toolsrv.ProcessKeeper
Proc *toolclient.Process
Keeper *toolclient.ProcessKeeper
Remote string
// Agent state
@ -242,6 +242,7 @@ func (s *Session) Resume() error {
Ctx: ctx,
CWD: cwd,
RemoteTarget: s.Remote,
SessionID: s.ID,
Yolo: pkgYolo,
})
if err != nil {
@ -276,7 +277,14 @@ func (s *Session) LoadTool(name string, ag *agent.Agent) error {
if name == "" {
return nil
}
return LoadToolOnConn(s.ToolsConn(), name)
conn := s.ToolsConn()
if conn == nil {
return nil
}
// Ensure session ID is set on toolsrv before loading - the server uses this
// to scope the tool registry. Required because conn may have been reconnected.
conn.SetEnv("OLLIE_SESSION_ID", s.ID)
return LoadToolOnConn(conn, name)
}
// LoadToolOnConn loads a tool on the given connection.
@ -284,13 +292,7 @@ func LoadToolOnConn(conn *toolsrv.Conn, name string) error {
if conn == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
args, _ := json.Marshal(map[string]string{"name": name})
if _, err := conn.CallTool(ctx, "tool_load", json.RawMessage(args)); err != nil {
return fmt.Errorf("remote load: %w", err)
}
return nil
return conn.LoadTool(name)
}
// --- Autosave ---

View File

@ -4,17 +4,18 @@ import (
"context"
"fmt"
"ollie/agent"
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/toolclient"
"ollie/paths"
"ollie/prompts"
"ollie/cmd/olliesrv/internal/prompts"
"ollie/toolsrv"
)
// SessionInfra holds the tool server infrastructure for a session.
// Created by SetupToolServer and used by both CreateAgent and restoreSession.
type SessionInfra struct {
Proc *toolsrv.Process
Keeper *toolsrv.ProcessKeeper
Proc *toolclient.Process
Keeper *toolclient.ProcessKeeper
ToolsConn *toolsrv.Conn
NewToolServer func() *toolsrv.Conn
Platform string
@ -26,6 +27,7 @@ type ToolServerConfig struct {
Ctx context.Context
CWD string
RemoteTarget string
SessionID string // session ID for tool registry
Yolo bool
// ReuseFrom, if non-nil, reuses existing infrastructure instead of spawning.
@ -35,8 +37,8 @@ type ToolServerConfig struct {
// InfraConfig describes existing infrastructure to reuse.
type InfraConfig struct {
Proc *toolsrv.Process
Keeper *toolsrv.ProcessKeeper
Proc *toolclient.Process
Keeper *toolclient.ProcessKeeper
ToolsConn *toolsrv.Conn
}
@ -75,25 +77,29 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) {
IsGitRepo: paths.IsGitRepo(cfg.CWD),
}, nil
}
var proc *toolsrv.Process
var keeper *toolsrv.ProcessKeeper
var proc *toolclient.Process
var keeper *toolclient.ProcessKeeper
var newToolServer func() *toolsrv.Conn
var err error
var platform string
var isGitRepo bool
if cfg.RemoteTarget != "" {
proc, err = toolsrv.SpawnRemote(cfg.Ctx, toolsrv.RemoteConfig{
proc, err = toolclient.SpawnRemote(cfg.Ctx, toolclient.RemoteConfig{
SSHTarget: cfg.RemoteTarget,
CWD: cfg.CWD,
SessionID: cfg.SessionID,
Yolo: cfg.Yolo,
})
if err != nil {
return nil, fmt.Errorf("remote spawn: %w", err)
}
keeper = toolsrv.NewProcessKeeper(cfg.Ctx, proc, func(ctx context.Context) (*toolsrv.Process, error) {
return toolsrv.SpawnRemote(ctx, toolsrv.RemoteConfig{
keeper = toolclient.NewProcessKeeper(cfg.Ctx, proc, func(ctx context.Context) (*toolclient.Process, error) {
return toolclient.SpawnRemote(ctx, toolclient.RemoteConfig{
SSHTarget: cfg.RemoteTarget,
CWD: cfg.CWD,
SessionID: cfg.SessionID,
Yolo: cfg.Yolo,
})
})
newToolServer = func() *toolsrv.Conn {
@ -106,16 +112,19 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) {
platform = proc.Info.Platform
isGitRepo = proc.Info.IsGitRepo
} else {
var dialOpts []toolsrv.Option
var dialOpts []toolclient.Option
if cfg.Yolo {
dialOpts = append(dialOpts, toolsrv.WithYolo())
dialOpts = append(dialOpts, toolclient.WithYolo())
}
proc, err = toolsrv.Spawn(cfg.Ctx, cfg.CWD, dialOpts...)
if cfg.SessionID != "" {
dialOpts = append(dialOpts, toolclient.WithSessionID(cfg.SessionID))
}
proc, err = toolclient.Spawn(cfg.Ctx, cfg.CWD, dialOpts...)
if err != nil {
return nil, fmt.Errorf("local spawn: %w", err)
}
keeper = toolsrv.NewProcessKeeper(cfg.Ctx, proc, func(ctx context.Context) (*toolsrv.Process, error) {
return toolsrv.Spawn(ctx, cfg.CWD, dialOpts...)
keeper = toolclient.NewProcessKeeper(cfg.Ctx, proc, func(ctx context.Context) (*toolclient.Process, error) {
return toolclient.Spawn(ctx, cfg.CWD, dialOpts...)
})
newToolServer = func() *toolsrv.Conn {
conn, err := keeper.Dial()

View File

@ -0,0 +1,403 @@
// spawn.go - Process spawning and lifecycle management for toolsrv.
package toolclient
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"ollie/paths"
toolsrvclient "ollie/toolsrv"
)
// Process represents a running toolsrv process.
type Process struct {
Cmd *exec.Cmd
SocketPath string
Secret string
Info ProcessInfo
cancel context.CancelFunc
}
// ProcessInfo contains metadata about the toolsrv process.
type ProcessInfo struct {
Platform string
IsGitRepo bool
}
// Option configures spawning behavior.
type Option func(*spawnConfig)
type spawnConfig struct {
yolo bool
sessionID string
}
// WithYolo disables sandbox enforcement.
func WithYolo() Option {
return func(c *spawnConfig) { c.yolo = true }
}
// WithSessionID sets the session ID for tool registry.
func WithSessionID(id string) Option {
return func(c *spawnConfig) { c.sessionID = id }
}
// Spawn starts a local toolsrv process.
func Spawn(ctx context.Context, cwd string, opts ...Option) (*Process, error) {
cfg := &spawnConfig{}
for _, opt := range opts {
opt(cfg)
}
// Generate socket path and secret
socketPath := filepath.Join(os.TempDir(), fmt.Sprintf("toolsrv-%d-%s.sock", os.Getpid(), randomHex(8)))
secret := randomHex(32)
// Find toolsrv binary
toolsrvPath, err := findToolsrv()
if err != nil {
return nil, fmt.Errorf("find toolsrv: %w", err)
}
// Build command
args := []string{"serve", "--cwd", cwd, "--listen", socketPath}
if cfg.yolo {
args = append(args, "--yolo")
}
if cfg.sessionID != "" {
args = append(args, "--session-id", cfg.sessionID)
}
procCtx, cancel := context.WithCancel(ctx)
cmd := exec.CommandContext(procCtx, toolsrvPath, args...)
// 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
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start toolsrv: %w", err)
}
// Wait for socket to be ready
if err := waitForSocket(socketPath, 5*time.Second); err != nil {
cancel()
cmd.Process.Kill()
return nil, fmt.Errorf("wait for socket: %w", err)
}
return &Process{
Cmd: cmd,
SocketPath: socketPath,
Secret: secret,
Info: ProcessInfo{
Platform: "linux",
IsGitRepo: false, // Caller can override
},
cancel: cancel,
}, nil
}
// Kill terminates the process.
func (p *Process) Kill() error {
if p.cancel != nil {
p.cancel()
}
if p.Cmd != nil && p.Cmd.Process != nil {
return p.Cmd.Process.Kill()
}
return nil
}
// Close terminates the process (alias for Kill).
func (p *Process) Close() error {
return p.Kill()
}
// Wait waits for the process to exit.
func (p *Process) Wait() error {
if p.Cmd != nil {
return p.Cmd.Wait()
}
return nil
}
// RemoteConfig configures remote toolsrv spawning.
type RemoteConfig struct {
SSHTarget string // user@host or host
CWD string
SessionID string // session ID for tool registry
Yolo bool // skip sandbox enforcement
}
// SpawnRemote starts a remote toolsrv process via SSH.
// Deploys the local ~/.config/ollie directory (including tools/*.meta) to the
// remote host, then starts toolsrv with SSH Unix socket forwarding.
func SpawnRemote(ctx context.Context, cfg RemoteConfig) (*Process, error) {
// Verify local config dir exists
cfgDir := paths.CfgDir()
if _, err := os.Stat(cfgDir); err != nil {
return nil, fmt.Errorf("local config dir not found at %s", cfgDir)
}
// Generate socket paths
sockDir := filepath.Join(paths.RuntimeDir(), "ollie")
os.MkdirAll(sockDir, 0700)
localSock := filepath.Join(sockDir, fmt.Sprintf("remote-%d-%s.sock", os.Getpid(), randomHex(8)))
remoteSock := fmt.Sprintf("/tmp/ollie-toolsrv-%d-%s.sock", os.Getpid(), randomHex(8))
// Build remote bootstrap script
// Extracts tarball to ~/.cache/ollie, sets XDG_CONFIG_HOME so tools/*.meta are found
var args []string
args = append(args, "serve", "--cwd", shellEscape(cfg.CWD), "--listen", shellEscape(remoteSock))
if cfg.SessionID != "" {
args = append(args, "--session-id", shellEscape(cfg.SessionID))
}
if cfg.Yolo {
args = append(args, "--yolo")
}
bootstrap := fmt.Sprintf(`#!/bin/sh
set -e
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/ollie"
mkdir -p "$CACHE_DIR"
tar xzf - -C "$CACHE_DIR"
export XDG_CONFIG_HOME="$(dirname "$CACHE_DIR")"
export PATH="$CACHE_DIR/bin:$PATH"
exec "$CACHE_DIR/bin/toolsrv" %s
`, strings.Join(args, " "))
// Build SSH command with socket forwarding
sshArgs := []string{
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "StreamLocalBindUnlink=yes",
"-L", localSock + ":" + remoteSock,
}
sshTarget := cfg.SSHTarget
if host, port, ok := strings.Cut(sshTarget, ":"); ok && port != "" {
sshTarget = host
sshArgs = append(sshArgs, "-p", port)
}
sshArgs = append(sshArgs, sshTarget, "bash -s")
procCtx, cancel := context.WithCancel(ctx)
cmd := exec.CommandContext(procCtx, "ssh", sshArgs...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGTERM,
}
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("ssh stdin pipe: %w", err)
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("ssh stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("ssh start: %w", err)
}
// Send tarball of config dir over stdin
if err := writeTarball(stdin, cfgDir); err != nil {
cancel()
cmd.Process.Kill()
return nil, fmt.Errorf("send tarball: %w", err)
}
// Send bootstrap script
if _, err := stdin.Write([]byte(bootstrap)); err != nil {
cancel()
cmd.Process.Kill()
return nil, fmt.Errorf("send bootstrap: %w", err)
}
stdin.Close()
// Wait for toolsrv to signal ready
readyCh := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stderrPipe)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// toolsrv prints "toolsrv: listening on <path>"
if strings.Contains(line, "listening on") {
close(readyCh)
return
}
}
}()
select {
case <-readyCh:
case <-time.After(30 * time.Second):
cancel()
cmd.Process.Kill()
return nil, fmt.Errorf("remote toolsrv startup timeout")
}
// Wait for local socket forwarding to be ready
if err := waitForSocket(localSock, 5*time.Second); err != nil {
cancel()
cmd.Process.Kill()
return nil, fmt.Errorf("wait for forwarded socket: %w", err)
}
return &Process{
Cmd: cmd,
SocketPath: localSock,
Secret: "", // Will be established on first Tauth
Info: ProcessInfo{
Platform: "linux", // TODO: detect from remote
IsGitRepo: false,
},
cancel: cancel,
}, nil
}
// writeTarball creates a gzipped tarball of the directory and writes it to w.
func writeTarball(w io.Writer, dir string) error {
cmd := exec.Command("tar", "czf", "-", "-C", dir, ".")
cmd.Stdout = w
cmd.Stderr = os.Stderr
return cmd.Run()
}
// shellEscape escapes a string for safe use in a shell command.
func shellEscape(s string) string {
if s == "" {
return "''"
}
if !strings.ContainsAny(s, " \t\n\r'\"\\$`!#&|;(){}") {
return s
}
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
// ProcessKeeper manages process lifecycle with automatic respawning.
type ProcessKeeper struct {
ctx context.Context
proc *Process
respawn func(context.Context) (*Process, error)
mu sync.Mutex
}
// NewProcessKeeper creates a new process keeper.
func NewProcessKeeper(ctx context.Context, proc *Process, respawn func(context.Context) (*Process, error)) *ProcessKeeper {
return &ProcessKeeper{
ctx: ctx,
proc: proc,
respawn: respawn,
}
}
// SetContext updates the context used for respawning.
func (pk *ProcessKeeper) SetContext(ctx context.Context) {
pk.mu.Lock()
defer pk.mu.Unlock()
pk.ctx = ctx
}
// Dial connects to the managed process, respawning if necessary.
func (pk *ProcessKeeper) Dial() (*toolsrvclient.Conn, error) {
pk.mu.Lock()
defer pk.mu.Unlock()
// Try to connect to existing process
if pk.proc != nil {
conn, err := toolsrvclient.Dial(pk.proc.SocketPath, pk.proc.Secret)
if err == nil {
// Save the secret if this was first connection
if pk.proc.Secret == "" {
pk.proc.Secret = conn.Secret()
}
return conn, nil
}
// Process may have died, try to respawn
}
if pk.respawn != nil {
proc, err := pk.respawn(pk.ctx)
if err != nil {
return nil, fmt.Errorf("respawn: %w", err)
}
pk.proc = proc
conn, err := toolsrvclient.Dial(proc.SocketPath, proc.Secret)
if err != nil {
return nil, err
}
return conn, nil
}
return nil, fmt.Errorf("no process available")
}
// Close terminates the managed process.
func (pk *ProcessKeeper) Close() error {
pk.mu.Lock()
defer pk.mu.Unlock()
if pk.proc != nil {
return pk.proc.Kill()
}
return nil
}
// --- Helpers ---
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
func findToolsrv() (string, error) {
// Check if toolsrv is in PATH
if path, err := exec.LookPath("toolsrv"); err == nil {
return path, nil
}
// Check common locations
home, _ := os.UserHomeDir()
candidates := []string{
filepath.Join(home, ".config", "ollie", "bin", "toolsrv"),
filepath.Join(home, "go", "bin", "toolsrv"),
"/usr/local/bin/toolsrv",
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("toolsrv binary not found in PATH or common locations")
}
func waitForSocket(path string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("unix", path, 100*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
time.Sleep(50 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for socket %s", path)
}

View File

@ -14,13 +14,13 @@ import (
"syscall"
"9fans.net/go/plan9/client"
"ollie/agent"
"ollie/bypass"
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/bypass"
"ollie/env"
"ollie/fs"
"ollie/cmd/olliesrv/internal/fs"
olog "ollie/log"
"ollie/paths"
"ollie/session"
"ollie/cmd/olliesrv/internal/session"
"github.com/godbus/dbus/v5"
)

View File

@ -14,9 +14,9 @@ import (
"sync"
"time"
"ollie/fs"
"ollie/cmd/olliesrv/internal/fs"
olog "ollie/log"
"ollie/session"
"ollie/cmd/olliesrv/internal/session"
"9fans.net/go/plan9"
)

17
cmd/toolsrv/auth9p.go Normal file
View File

@ -0,0 +1,17 @@
// 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
}

380
cmd/toolsrv/exec9p.go Normal file
View File

@ -0,0 +1,380 @@
// exec9p.go - Decoupled tool execution for 9P toolsrv
package main
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"ollie/cmd/toolsrv/internal/sandbox"
"ollie/paths"
"ollie/toolsrv"
)
// ExecConfig contains all context needed for tool execution.
// This decouples execution from Server state.
type ExecConfig struct {
CWD string
Env map[string]string
Yolo bool
Timeout int
}
// StreamFunc returns the streaming output function from context, if any.
func StreamFunc(ctx context.Context) func(string) {
if fn, ok := ctx.Value(streamKey{}).(func(string)); ok {
return fn
}
return nil
}
type streamKey struct{}
// WithStreamFunc attaches a streaming output function to context.
func WithStreamFunc(ctx context.Context, fn func(string)) context.Context {
return context.WithValue(ctx, streamKey{}, fn)
}
// 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) {
// Resolve script path
toolPath, err := toolsrv.ResolveTool(info.Name)
if err != nil {
return nil, fmt.Errorf("resolve tool %s: %w", info.Name, err)
}
// Extract dispatch-level flags from args
bypassed := false
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 30
}
sandboxName := "default"
var argMap map[string]interface{}
if err := json.Unmarshal(args, &argMap); err == nil {
if e, ok := argMap["bypass"]; ok {
switch v := e.(type) {
case bool:
bypassed = v
case string:
bypassed = v == "true" || v == "1"
}
delete(argMap, "bypass")
}
if t, ok := argMap["timeout"]; ok {
switch v := t.(type) {
case float64:
timeout = int(v)
case string:
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
timeout = n
}
}
delete(argMap, "timeout")
}
if s, ok := argMap["sandbox"]; ok {
if v, ok := s.(string); ok && v != "" {
sandboxName = v
}
delete(argMap, "sandbox")
}
args, _ = json.Marshal(argMap)
}
// Check if tool requires sudo
needsSudo := false
if m, err := toolsrv.LoadMetaFile(info.Name); err == nil && m != nil {
resolved := m.Resolve()
if resolved != nil && resolved.Sudo {
needsSudo = true
bypassed = true
}
}
stdinData := string(args)
cwd := cfg.CWD
if cwd == "" {
cwd, _ = os.Getwd()
}
var result string
if needsSudo {
sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", toolPath, stdinData)
result, err = executeBypassDirect(ctx, sudoCode, cwd, cfg.Env, timeout, true)
} else if bypassed {
bypassCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", toolPath, stdinData)
result, err = executeBypassDirect(ctx, bypassCode, cwd, cfg.Env, timeout, false)
} else {
result, err = executeSandboxed(ctx, toolPath, stdinData, cwd, cfg.Env, timeout, sandboxName, cfg.Yolo)
}
if err != nil {
return json.Marshal(toolsrv.ToolResult{
IsError: true,
Content: []toolsrv.ToolResultContent{{Type: "text", Text: result + ": " + err.Error()}},
})
}
return json.Marshal(toolsrv.ToolResult{
Content: []toolsrv.ToolResultContent{{Type: "text", Text: result}},
})
}
// executeSandboxed runs a tool script inside the sandbox.
func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envExtra map[string]string, timeout int, sandboxName string, yolo bool) (string, error) {
if timeout <= 0 {
timeout = 30
}
var sandboxCfg *sandbox.Config
if !yolo {
cfgPath := filepath.Join(paths.CfgDir(), "sandbox", sandboxName+".yaml")
f, err := os.Open(cfgPath)
if err != nil {
return "", fmt.Errorf("sandbox %q not found: %w", sandboxName, err)
}
defer f.Close()
var loadErr error
sandboxCfg, loadErr = sandbox.LoadSandbox(f)
if loadErr != nil {
return "", loadErr
}
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
interpreter := []string{"bash", "-c", toolPath}
// Build environment
envMap := make(map[string]string)
for _, ev := range os.Environ() {
k, v, _ := strings.Cut(ev, "=")
envMap[k] = v
}
for k, v := range envExtra {
envMap[k] = v
}
// Compute NAMESPACE if not set
if _, ok := envMap["NAMESPACE"]; !ok {
if ns := plan9Namespace(envMap); ns != "" {
envMap["NAMESPACE"] = ns
}
}
getenv := func(key string) string { return envMap[key] }
var cmd *exec.Cmd
if yolo {
cmd = exec.CommandContext(ctx, interpreter[0], interpreter[1:]...)
} else {
wrapped, wrapErr := sandbox.WrapCommand(sandboxCfg, interpreter, cwd, getenv)
if wrapErr != nil {
return "", wrapErr
}
cmd = exec.CommandContext(ctx, wrapped[0], wrapped[1:]...)
}
cmd.Dir = cwd
if stdinData != "" {
cmd.Stdin = strings.NewReader(stdinData)
}
// Set environment
for k, v := range envMap {
cmd.Env = append(cmd.Env, k+"="+v)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = time.Second
var outputBuf bytes.Buffer
lw := &limitedWriter{
w: &outputBuf,
limit: 10 * 1024 * 1024,
stream: StreamFunc(ctx),
}
cmd.Stdout = lw
cmd.Stderr = lw
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("execution failed: %w", err)
}
err := cmd.Wait()
output := outputBuf.Bytes()
if lw.truncated {
output = append(output, []byte("\n[output truncated at 10MB]")...)
}
if ctx.Err() == context.DeadlineExceeded {
return string(output), fmt.Errorf("execution timeout after %d seconds", timeout)
}
if err != nil {
errOutput := string(output)
const maxErrOutput = 8192
if len(errOutput) > maxErrOutput {
errOutput = errOutput[:maxErrOutput] + fmt.Sprintf("\n[...truncated %d bytes in error]", len(output)-maxErrOutput)
}
return string(output), fmt.Errorf("execution failed: %w\nOutput: %s", err, errOutput)
}
return string(output), nil
}
// executeBypassDirect runs a command via the bypass broker.
func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[string]string, timeout int, sudo bool) (string, error) {
xdg := os.Getenv("XDG_RUNTIME_DIR")
if xdg == "" {
return "", fmt.Errorf("bypass not available: no XDG_RUNTIME_DIR")
}
sockPath := filepath.Join(xdg, "ollie", "bypass.sock")
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
// Build environment map
envMap := make(map[string]string)
for _, kv := range os.Environ() {
if k, v, ok := strings.Cut(kv, "="); ok {
envMap[k] = v
}
}
for k, v := range envExtra {
envMap[k] = v
}
// Connect and send request
conn, err := net.DialTimeout("unix", sockPath, 5*time.Second)
if err != nil {
return "", fmt.Errorf("bypass not available: %w", err)
}
defer conn.Close()
reqJSON, _ := json.Marshal(struct {
Cmd string `json:"cmd"`
Cwd string `json:"cwd"`
Env map[string]string `json:"env"`
Sudo bool `json:"sudo,omitempty"`
}{Cmd: cmd, Cwd: cwd, Env: envMap, Sudo: sudo})
reqJSON = append(reqJSON, '\n')
if _, err := conn.Write(reqJSON); err != nil {
return "", fmt.Errorf("bypass write failed: %w", err)
}
// Read response frames
var outputBuf bytes.Buffer
exitCode := readBypassFrames(ctx, conn, &outputBuf, StreamFunc(ctx))
output := outputBuf.String()
if exitCode != 0 {
return output, fmt.Errorf("bypass execution failed (exit %d)", exitCode)
}
return output, nil
}
// readBypassFrames reads framed output from bypass broker.
func readBypassFrames(ctx context.Context, conn net.Conn, w *bytes.Buffer, stream func(string)) int {
header := make([]byte, 5)
for {
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
_, err := io.ReadFull(conn, header)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
select {
case <-ctx.Done():
return -1
default:
continue
}
}
return -1
}
frameType := header[0]
length := binary.BigEndian.Uint32(header[1:5])
payload := make([]byte, length)
if length > 0 {
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
if _, err := io.ReadFull(conn, payload); err != nil {
return -1
}
}
switch frameType {
case 'd':
w.Write(payload)
if stream != nil {
stream(string(payload))
}
case 'x':
var code int
fmt.Sscanf(string(payload), "%d", &code)
return code
}
}
}
// plan9Namespace computes the Plan 9 namespace directory.
func plan9Namespace(env map[string]string) string {
if ns := env["NAMESPACE"]; ns != "" {
return ns
}
disp := env["DISPLAY"]
if disp == "" {
return "/tmp/ns." + env["USER"] + "." + "unix"
}
// Strip leading "localhost" if present
disp = strings.TrimPrefix(disp, "localhost")
disp = strings.ReplaceAll(disp, "/", "_")
return "/tmp/ns." + env["USER"] + "." + disp
}
// limitedWriter wraps a writer with a size limit and optional streaming.
type limitedWriter struct {
w io.Writer
limit int
written int
truncated bool
stream func(string)
}
func (lw *limitedWriter) Write(p []byte) (int, error) {
if lw.truncated {
return len(p), nil // discard but report success
}
remaining := lw.limit - lw.written
if remaining <= 0 {
lw.truncated = true
return len(p), nil
}
toWrite := p
if len(p) > remaining {
toWrite = p[:remaining]
lw.truncated = true
}
n, err := lw.w.Write(toWrite)
lw.written += n
if lw.stream != nil && n > 0 {
lw.stream(string(toWrite[:n]))
}
return len(p), err
}

596
cmd/toolsrv/fs9p.go Normal file
View File

@ -0,0 +1,596 @@
// 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
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"ollie/toolsrv"
)
// FS9P implements a 9P-style tool server.
// This is the core logic; the actual 9P serving is handled separately.
type FS9P struct {
mu sync.RWMutex
cwd string
env map[string]string
registry *Registry
sessID string
yolo bool
// Process management
procMu sync.Mutex
procs map[int]*Proc9P
nextPID int
procLimit int
// Callbacks
OnToolsChanged func()
}
// Proc9P represents a running or completed process.
type Proc9P struct {
PID int
Tool string
Args map[string]string
StartTime time.Time
EndTime time.Time
ExitCode int
Exited bool
mu sync.Mutex
output bytes.Buffer
done chan struct{}
cancel context.CancelFunc
proc *os.Process // for signaling
}
// NewFS9P creates a new 9P tool server.
func NewFS9P(cwd string) *FS9P {
return &FS9P{
cwd: cwd,
env: make(map[string]string),
procs: make(map[int]*Proc9P),
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()
}
// SetYolo enables/disables sandbox bypass.
func (fs *FS9P) SetYolo(yolo bool) {
fs.mu.Lock()
fs.yolo = yolo
fs.mu.Unlock()
}
// CWD returns the current working directory.
func (fs *FS9P) CWD() string {
fs.mu.RLock()
defer fs.mu.RUnlock()
return fs.cwd
}
// SetCWD sets the current working directory.
func (fs *FS9P) SetCWD(dir string) {
fs.mu.Lock()
fs.cwd = dir
fs.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)
}
fs.env[key] = value
fs.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]
}
// --- 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()
if reg == nil || sid == "" {
return nil
}
return reg.Loaded(sid)
}
// 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()
if reg == nil {
return fmt.Errorf("no tool registry configured")
}
if sid == "" {
return fmt.Errorf("no session ID configured")
}
if err := reg.Load(sid, name); err != nil {
return err
}
if fs.OnToolsChanged != nil {
fs.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()
if reg == nil {
return fmt.Errorf("no tool registry configured")
}
if sid == "" {
return fmt.Errorf("no session ID configured")
}
if err := reg.Unload(sid, name); err != nil {
return err
}
if fs.OnToolsChanged != nil {
fs.OnToolsChanged()
}
return nil
}
// --- 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++
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) {
// Parse 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)
}
fs.procMu.Unlock()
// Look up tool
fs.mu.RLock()
reg := fs.registry
sid := fs.sessID
cwd := fs.cwd
yolo := fs.yolo
envCopy := make(map[string]string)
for k, v := range fs.env {
envCopy[k] = v
}
fs.mu.RUnlock()
if reg == nil || sid == "" {
return "", 0, fmt.Errorf("tool registry not configured")
}
info, ok := reg.Lookup(sid, toolName)
if !ok {
return "", 0, fmt.Errorf("tool not found: %s", toolName)
}
// Create proc
procCtx, cancel := context.WithCancel(ctx)
proc := &Proc9P{
PID: fs.allocPID(),
Tool: toolName,
Args: args,
StartTime: time.Now(),
done: make(chan struct{}),
cancel: cancel,
}
fs.procMu.Lock()
fs.procs[proc.PID] = proc
fs.procMu.Unlock()
// Execute in goroutine
go func() {
defer close(proc.done)
defer cancel()
out, exitCode := fs.executeTool(procCtx, info, args, cwd, envCopy, yolo)
proc.mu.Lock()
proc.output.WriteString(out)
proc.ExitCode = exitCode
proc.Exited = true
proc.EndTime = time.Now()
proc.mu.Unlock()
}()
if background {
return fmt.Sprintf("%d", proc.PID), proc.PID, nil
}
// Wait for completion
<-proc.done
proc.mu.Lock()
result = proc.output.String()
exitCode := proc.ExitCode
proc.mu.Unlock()
// Auto-cleanup for foreground procs
fs.procMu.Lock()
delete(fs.procs, proc.PID)
fs.procMu.Unlock()
if exitCode != 0 {
return result, proc.PID, fmt.Errorf("exit %d", exitCode)
}
return result, proc.PID, nil
}
// 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) {
// Convert args map to JSON for the execution path
jsonArgs := argsToJSON(args)
cfg := ExecConfig{
CWD: cwd,
Env: envExtra,
Yolo: yolo,
}
result, err := ExecuteTool(ctx, info, jsonArgs, cfg)
if err != nil {
return fmt.Sprintf("error: %v", err), 1
}
return string(result), 0
}
// GetProc returns a process by PID.
func (fs *FS9P) GetProc(pid int) *Proc9P {
fs.procMu.Lock()
defer fs.procMu.Unlock()
return fs.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 {
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)
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)
if proc == nil {
return fmt.Errorf("process not found: %d", pid)
}
// Cancel the context (for graceful stop)
if sig == syscall.SIGTERM || sig == syscall.SIGINT {
proc.cancel()
}
// Also signal the underlying process if available
proc.mu.Lock()
p := proc.proc
proc.mu.Unlock()
if p != nil {
p.Signal(sig)
}
return nil
}
// --- Host Info ---
// HostInfo9P returns host information.
func (fs *FS9P) HostInfo9P() string {
fs.mu.RLock()
cwd := fs.cwd
fs.mu.RUnlock()
return fmt.Sprintf("platform=%s\narch=%s\ncwd=%s\ngit=%v\n",
runtime.GOOS, runtime.GOARCH, cwd, isGitRepo9P(cwd))
}
func isGitRepo9P(dir string) bool {
info, err := os.Stat(dir + "/.git")
return err == nil && info.IsDir()
}
// --- Payload Parsing ---
// 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
}
idx := strings.Index(line, "=")
if idx < 0 {
continue
}
key := line[:idx]
value := line[idx+1:]
// Unescape \n and \\ in one pass to handle \\n correctly
value = unescapeValue(value)
args[key] = value
}
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)
}
// 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
for k, v := range args {
if k == "tool" {
continue // tool name is not part of the args
}
if !first {
buf.WriteByte(',')
}
first = false
buf.WriteByte('"')
buf.WriteString(escapeJSON(k))
buf.WriteString("\":")
buf.WriteByte('"')
buf.WriteString(escapeJSON(v))
buf.WriteByte('"')
}
buf.WriteByte('}')
return buf.Bytes()
}
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "\\r")
s = strings.ReplaceAll(s, "\t", "\\t")
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
func init() {
globalProcCounter.Store(time.Now().UnixNano() % 10000)
}

216
cmd/toolsrv/fs9p_test.go Normal file
View File

@ -0,0 +1,216 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
func TestFS9P_Basic(t *testing.T) {
fs := NewFS9P("/tmp")
// Test CWD
if fs.CWD() != "/tmp" {
t.Errorf("CWD() = %q, want /tmp", fs.CWD())
}
fs.SetCWD("/home")
if fs.CWD() != "/home" {
t.Errorf("CWD() = %q, want /home", fs.CWD())
}
// Test Env
fs.SetEnv("FOO", "bar")
if fs.GetEnv("FOO") != "bar" {
t.Errorf("GetEnv(FOO) = %q, want bar", fs.GetEnv("FOO"))
}
// Test HostInfo
info := fs.HostInfo9P()
if !strings.Contains(info, "platform=") {
t.Errorf("HostInfo9P() missing platform")
}
}
func TestFS9P_ToolsWithoutRegistry(t *testing.T) {
fs := NewFS9P("/tmp")
// Without registry, ListTools should return nil
tools := fs.ListTools()
if tools != nil {
t.Errorf("ListTools() without registry = %v, want nil", tools)
}
// Load should fail without registry
err := fs.LoadTool("shell")
if err == nil {
t.Error("LoadTool without registry should fail")
}
}
func TestFS9P_HandleCtl(t *testing.T) {
fs := NewFS9P("/tmp")
// Unknown command
err := fs.HandleCtl("unknown")
if err == nil {
t.Error("HandleCtl(unknown) should fail")
}
// load without name
err = fs.HandleCtl("load")
if err == nil {
t.Error("HandleCtl(load) without name should fail")
}
// unload without name
err = fs.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")
// No procs initially
pids := fs.ListProcs()
if len(pids) != 0 {
t.Errorf("ListProcs() = %v, want empty", pids)
}
// GetProc for non-existent
if fs.GetProc(999) != nil {
t.Error("GetProc(999) should return nil")
}
// DismissProc for non-existent
if fs.DismissProc(999) {
t.Error("DismissProc(999) should return false")
}
// SignalProc for non-existent
if err := fs.SignalProc(999, 15); err == nil {
t.Error("SignalProc(999) should fail")
}
}
func TestFS9P_HandleProcCtl(t *testing.T) {
fs := NewFS9P("/tmp")
// Unknown command
err := fs.HandleProcCtl(1, "unknown")
if err == nil {
t.Error("HandleProcCtl(unknown) should fail")
}
// signal without number
err = fs.HandleProcCtl(1, "signal")
if err == nil {
t.Error("HandleProcCtl(signal) without number should fail")
}
// signal with invalid number
err = fs.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")
if err != nil {
t.Errorf("HandleProcCtl(dismiss) on non-existent = %v, want nil", err)
}
}
func TestProc9P_Stat(t *testing.T) {
proc := &Proc9P{
PID: 1,
Tool: "shell",
StartTime: time.Now(),
done: make(chan struct{}),
}
// Running state
stat := proc.Stat()
if !strings.Contains(stat, "running") {
t.Errorf("Stat() for running proc should contain 'running', got %q", stat)
}
// Mark as exited
proc.Exited = true
proc.ExitCode = 0
proc.EndTime = time.Now()
stat = proc.Stat()
if !strings.Contains(stat, "exited 0") {
t.Errorf("Stat() for exited proc should contain 'exited 0', got %q", stat)
}
}
func TestFS9P_NewProcWithoutRegistry(t *testing.T) {
fs := NewFS9P("/tmp")
ctx := context.Background()
_, _, err := fs.NewProc(ctx, "tool=shell\ncmd=ls\n", false)
if err == nil {
t.Error("NewProc without registry should fail")
}
}

View File

@ -0,0 +1,469 @@
// integration_test.go - End-to-end tests for 9P toolsrv with Tauth.
//
// These tests build and spawn a real toolsrv process, then connect via 9P
// to verify the authentication flow works correctly.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"ollie/toolsrv"
)
var testToolsrvBinary string
// TestMain builds the toolsrv binary once for all integration tests.
func TestMain(m *testing.M) {
// Build toolsrv binary to a temp location
tmpDir, err := os.MkdirTemp("", "toolsrv-test-*")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create temp dir: %v\n", err)
os.Exit(1)
}
defer os.RemoveAll(tmpDir)
testToolsrvBinary = filepath.Join(tmpDir, "toolsrv")
// Build the binary
cmd := exec.Command("go", "build", "-o", testToolsrvBinary, "ollie/cmd/toolsrv")
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "failed to build toolsrv: %v\n", err)
os.Exit(1)
}
os.Exit(m.Run())
}
// startTestServer starts a toolsrv process for testing.
// Returns socketPath and a cleanup function.
func startTestServer(t *testing.T) (socketPath string, cleanup func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "toolsrv-socket-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
socketPath = filepath.Join(tmpDir, "toolsrv.sock")
cwd, _ := os.Getwd()
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, testToolsrvBinary, "serve", "--cwd", cwd, "--listen", socketPath)
cmd.Stderr = os.Stderr
// Set session ID so tool registry is configured
cmd.Env = append(os.Environ(), "OLLIE_SESSION_ID=test-session")
if err := cmd.Start(); err != nil {
cancel()
os.RemoveAll(tmpDir)
t.Fatalf("failed to start toolsrv: %v", err)
}
// Wait for socket to be ready
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(socketPath); err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if _, err := os.Stat(socketPath); err != nil {
cancel()
cmd.Wait()
os.RemoveAll(tmpDir)
t.Fatalf("toolsrv socket not ready: %v", err)
}
return socketPath, func() {
cancel()
cmd.Wait()
os.RemoveAll(tmpDir)
}
}
func TestIntegration_FirstConnectionSetsSecret(t *testing.T) {
socketPath, cleanup := startTestServer(t)
defer cleanup()
// First connection with a secret should succeed
secret := "test-secret-first-connection"
conn, err := toolsrv.Dial(socketPath, secret)
if err != nil {
t.Fatalf("first Dial failed: %v", err)
}
defer conn.Close()
// Should have received a token
if conn.Token() == "" {
t.Error("expected non-empty token")
}
// Secret should be stored
if conn.Secret() != secret {
t.Errorf("Secret() = %q, want %q", conn.Secret(), secret)
}
}
func TestIntegration_SameSecretReconnects(t *testing.T) {
socketPath, cleanup := startTestServer(t)
defer cleanup()
secret := "test-secret-reconnect"
// First connection establishes the secret
conn1, err := toolsrv.Dial(socketPath, secret)
if err != nil {
t.Fatalf("first Dial failed: %v", err)
}
token1 := conn1.Token()
conn1.Close()
// Second connection with same secret should succeed
conn2, err := toolsrv.Dial(socketPath, secret)
if err != nil {
t.Fatalf("second Dial failed: %v", err)
}
defer conn2.Close()
// Should get the same token
if conn2.Token() != token1 {
t.Errorf("token changed: %q -> %q", token1, conn2.Token())
}
}
func TestIntegration_WrongSecretFails(t *testing.T) {
socketPath, cleanup := startTestServer(t)
defer cleanup()
// First connection establishes the secret
conn1, err := toolsrv.Dial(socketPath, "correct-secret")
if err != nil {
t.Fatalf("first Dial failed: %v", err)
}
conn1.Close()
// Second connection with wrong secret should fail
conn2, err := toolsrv.Dial(socketPath, "wrong-secret")
if err == nil {
conn2.Close()
t.Fatal("Dial with wrong secret should fail")
}
}
func TestIntegration_EmptySecretGeneratesOne(t *testing.T) {
socketPath, cleanup := startTestServer(t)
defer cleanup()
// Empty secret should generate a random one
conn, err := toolsrv.Dial(socketPath, "")
if err != nil {
t.Fatalf("Dial with empty secret failed: %v", err)
}
defer conn.Close()
// Should have generated and stored a secret
if conn.Secret() == "" {
t.Error("expected non-empty generated secret")
}
// Token should be set
if conn.Token() == "" {
t.Error("expected non-empty token")
}
}
func TestIntegration_BasicOperations(t *testing.T) {
socketPath, cleanup := startTestServer(t)
defer cleanup()
conn, err := toolsrv.Dial(socketPath, "test-secret-ops")
if err != nil {
t.Fatalf("Dial failed: %v", err)
}
defer conn.Close()
// Test Ping
if err := conn.Ping(); err != nil {
t.Errorf("Ping failed: %v", err)
}
// Test HostInfo
info, err := conn.HostInfo()
if err != nil {
t.Errorf("HostInfo failed: %v", err)
}
if info.Platform == "" {
t.Error("HostInfo.Platform is empty")
}
// Test ListTools (should be empty initially, but shouldn't error)
tools, err := conn.ListTools()
if err != nil {
t.Errorf("ListTools failed: %v", err)
}
t.Logf("ListTools returned %d tools", len(tools))
}
func TestIntegration_ToolExecution(t *testing.T) {
// Skip if tools directory doesn't exist (CI environment)
if _, err := os.ReadDir(toolsrv.ToolsPath()); err != nil {
t.Skipf("skipping: tools directory not available: %v", err)
}
socketPath, cleanup := startTestServer(t)
defer cleanup()
conn, err := toolsrv.Dial(socketPath, "test-secret-exec")
if err != nil {
t.Fatalf("Dial failed: %v", err)
}
defer conn.Close()
// Load the shell tool
if err := conn.LoadTool("shell"); err != nil {
t.Skipf("skipping: LoadTool failed (shell tool not available): %v", err)
}
// Verify it's loaded
tools, err := conn.ListTools()
if err != nil {
t.Fatalf("ListTools failed: %v", err)
}
found := false
for _, tool := range tools {
if tool.Name == "shell" {
found = true
break
}
}
if !found {
t.Fatal("shell tool not found after loading")
}
// Execute a tool - this mimics what agent/runtime.go does
ctx := context.Background()
args := json.RawMessage(`{"cmd": "echo hello from test"}`)
result, err := conn.CallTool(ctx, "shell", args)
if err != nil {
t.Fatalf("CallTool failed: %v", err)
}
// Parse the result the same way agent/runtime.go does
var toolResult toolsrv.ToolResult
if err := json.Unmarshal(result, &toolResult); err != nil {
t.Fatalf("failed to parse toolsrv.ToolResult: %v (raw: %s)", err, string(result))
}
// Verify structure
if len(toolResult.Content) == 0 {
t.Fatal("toolsrv.ToolResult.Content is empty")
}
if toolResult.Content[0].Type != "text" {
t.Errorf("expected type 'text', got %q", toolResult.Content[0].Type)
}
if !strings.Contains(toolResult.Content[0].Text, "hello from test") {
t.Errorf("output doesn't contain expected text: %q", toolResult.Content[0].Text)
}
if toolResult.IsError {
t.Error("toolsrv.ToolResult.IsError should be false")
}
t.Logf("Tool result: %+v", toolResult)
}
func TestIntegration_MultilineContent(t *testing.T) {
// Skip if tools directory doesn't exist (CI environment)
if _, err := os.ReadDir(toolsrv.ToolsPath()); err != nil {
t.Skipf("skipping: tools directory not available: %v", err)
}
socketPath, cleanup := startTestServer(t)
defer cleanup()
conn, err := toolsrv.Dial(socketPath, "test-secret-multiline")
if err != nil {
t.Fatalf("Dial failed: %v", err)
}
defer conn.Close()
// Load the file_write tool
if err := conn.LoadTool("file_write"); err != nil {
t.Skipf("skipping: LoadTool failed (file_write tool not available): %v", err)
}
// Create a temp file path
tmpFile := filepath.Join(t.TempDir(), "multiline_test.txt")
// Test with multiline content including special characters
multilineContent := "line one\nline two\nline three with \"quotes\"\nline four with 'single quotes'\nline five with \\backslash\n"
ctx := context.Background()
args := json.RawMessage(fmt.Sprintf(`{"path": %q, "content": %q}`, tmpFile, multilineContent))
result, err := conn.CallTool(ctx, "file_write", args)
if err != nil {
t.Fatalf("CallTool failed: %v", err)
}
var toolResult toolsrv.ToolResult
if err := json.Unmarshal(result, &toolResult); err != nil {
t.Fatalf("failed to parse toolsrv.ToolResult: %v (raw: %s)", err, string(result))
}
if toolResult.IsError {
t.Fatalf("file_write returned error: %s", toolResult.Content[0].Text)
}
// Read the file back and verify
written, err := os.ReadFile(tmpFile)
if err != nil {
t.Fatalf("failed to read written file: %v", err)
}
if string(written) != multilineContent {
t.Errorf("content mismatch:\nexpected: %q\ngot: %q", multilineContent, string(written))
}
}
func TestIntegration_ToolExecutionError(t *testing.T) {
// Skip if tools directory doesn't exist (CI environment)
if _, err := os.ReadDir(toolsrv.ToolsPath()); err != nil {
t.Skipf("skipping: tools directory not available: %v", err)
}
socketPath, cleanup := startTestServer(t)
defer cleanup()
conn, err := toolsrv.Dial(socketPath, "test-secret-exec-err")
if err != nil {
t.Fatalf("Dial failed: %v", err)
}
defer conn.Close()
// Load the shell tool
if err := conn.LoadTool("shell"); err != nil {
t.Skipf("skipping: LoadTool failed (shell tool not available): %v", err)
}
// Execute a command that fails
ctx := context.Background()
args := json.RawMessage(`{"cmd": "exit 42"}`)
result, err := conn.CallTool(ctx, "shell", args)
if err != nil {
t.Fatalf("CallTool failed: %v", err)
}
// Parse the result
var toolResult toolsrv.ToolResult
if err := json.Unmarshal(result, &toolResult); err != nil {
t.Fatalf("failed to parse toolsrv.ToolResult: %v (raw: %s)", err, string(result))
}
// Should have exit code in output
if len(toolResult.Content) == 0 {
t.Fatal("toolsrv.ToolResult.Content is empty")
}
if !strings.Contains(toolResult.Content[0].Text, "exit: 42") {
t.Errorf("output doesn't contain exit code: %q", toolResult.Content[0].Text)
}
t.Logf("Error tool result: %+v", toolResult)
}
func TestIntegration_ConcurrentConnections(t *testing.T) {
socketPath, cleanup := startTestServer(t)
defer cleanup()
secret := "test-secret-concurrent"
// Establish the secret
conn0, err := toolsrv.Dial(socketPath, secret)
if err != nil {
t.Fatalf("initial Dial failed: %v", err)
}
expectedToken := conn0.Token()
conn0.Close()
// Open multiple concurrent connections
const numConns = 5
conns := make([]*toolsrv.Conn, numConns)
errors := make([]error, numConns)
for i := 0; i < numConns; i++ {
conns[i], errors[i] = toolsrv.Dial(socketPath, secret)
}
// Check all succeeded with same token
for i := 0; i < numConns; i++ {
if errors[i] != nil {
t.Errorf("connection %d failed: %v", i, errors[i])
continue
}
if conns[i].Token() != expectedToken {
t.Errorf("connection %d has different token", i)
}
conns[i].Close()
}
}
func TestIntegration_ProcessKeeperRespawn(t *testing.T) {
// This test verifies that when a toolsrv process dies,
// the ProcessKeeper respawns it with a new secret.
cwd, _ := os.Getwd()
// We need to use the real Spawn which requires the binary in PATH
// For now, manually simulate what ProcessKeeper does
// Start first server
socketPath1, cleanup1 := startTestServer(t)
secret1 := "secret-for-first-server"
conn1, err := toolsrv.Dial(socketPath1, secret1)
if err != nil {
cleanup1()
t.Fatalf("first Dial failed: %v", err)
}
token1 := conn1.Token()
conn1.Close()
// Kill the server
cleanup1()
// Start second server (simulates respawn)
socketPath2, cleanup2 := startTestServer(t)
defer cleanup2()
// New server should accept a new secret (any secret, since it's fresh)
secret2 := "secret-for-second-server"
conn2, err := toolsrv.Dial(socketPath2, secret2)
if err != nil {
t.Fatalf("second Dial failed: %v", err)
}
token2 := conn2.Token()
conn2.Close()
// Tokens should be different (different servers)
if token1 == token2 {
t.Error("tokens should be different after respawn")
}
// Old secret should NOT work on new server
conn3, err := toolsrv.Dial(socketPath2, secret1)
if err == nil {
conn3.Close()
t.Error("old secret should not work on new server")
}
t.Logf("Successfully verified: old_token=%s new_token=%s", token1[:8], token2[:8])
_ = cwd // silence unused warning
}

121
cmd/toolsrv/main.go Normal file
View File

@ -0,0 +1,121 @@
// toolsrv — 9P tool execution server.
//
// Executes tool calls in a sandboxed environment. Used both locally
// (spawned by olliesrv) and remotely (deployed via SSH bootstrap).
//
// Usage:
//
// toolsrv serve --cwd /path/to/project --listen /path/to/sock [--yolo]
//
// Listens on a Unix socket and serves a 9P filesystem for tool execution.
// Authentication happens via Tauth: first client to connect sets the secret,
// subsequent clients must provide the same secret.
package main
import (
"context"
"flag"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"syscall"
"ollie/env"
"ollie/fsedsl"
)
var (
cwd = flag.String("cwd", ".", "working directory for execution")
listenPath = flag.String("listen", "", "Unix socket path to listen on (required)")
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)")
)
func main() {
if len(os.Args) < 2 || os.Args[1] != "serve" {
fmt.Fprintln(os.Stderr, "usage: toolsrv serve --cwd <path> --listen <socket> [--yolo]")
os.Exit(1)
}
flag.CommandLine.Parse(os.Args[2:])
if *listenPath == "" {
fmt.Fprintln(os.Stderr, "error: --listen is required")
os.Exit(1)
}
env.EnsureDefaults()
// Prepend our bin dir to PATH so landrun is found (deployed alongside us).
home, _ := os.UserHomeDir()
binDir := filepath.Join(home, ".config", "ollie", "bin")
os.Setenv("PATH", binDir+":"+os.Getenv("PATH"))
// Create tool registry
toolReg, _ := NewRegistry()
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()
srv.SetYolo(*yolo)
if toolReg != nil && sid != "" {
srv.SetRegistry(toolReg, sid)
}
// Build the filesystem tree
ctx := ToolsrvCtx{Server: srv}
tree := fsedsl.BuildTree(ToolsrvSpec(), ctx)
if tree == nil {
fmt.Fprintln(os.Stderr, "error: failed to build filesystem tree")
os.Exit(1)
}
// Set up signal handling
runCtx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
cancel()
}()
// 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)
os.Exit(1)
}
defer ln.Close()
defer os.Remove(*listenPath)
fmt.Fprintf(os.Stderr, "toolsrv: listening on %s\n", *listenPath)
// Accept connections
go func() {
<-runCtx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-runCtx.Done():
return
default:
fmt.Fprintf(os.Stderr, "accept error: %v\n", err)
continue
}
}
go serve9P(runCtx, conn, tree, srv, *noAuth)
}
}

114
cmd/toolsrv/registry.go Normal file
View File

@ -0,0 +1,114 @@
// registry.go - Session-scoped tool registry for toolsrv.
package main
import (
"fmt"
"sort"
"sync"
"ollie/toolsrv"
)
// Registry tracks loaded tools per session.
type Registry struct {
mu sync.RWMutex
sessions map[string]map[string]toolsrv.ToolInfo
revisions map[string]uint64
}
// NewRegistry creates a new tool registry.
func NewRegistry() (*Registry, error) {
r := &Registry{
sessions: make(map[string]map[string]toolsrv.ToolInfo),
revisions: make(map[string]uint64),
}
return r, nil
}
// discover scans the tools directory and returns all valid tool infos.
func (r *Registry) discover() map[string]toolsrv.ToolInfo {
infos := toolsrv.DiscoverTools()
tools := make(map[string]toolsrv.ToolInfo, len(infos))
for _, info := range infos {
tools[info.Name] = info
}
return tools
}
// Load loads a tool into a session's registry.
func (r *Registry) Load(sessionID, name string) error {
tools := r.discover()
tool, exists := tools[name]
if !exists {
return fmt.Errorf("tool not found: %s", name)
}
r.mu.Lock()
defer r.mu.Unlock()
if r.sessions[sessionID] == nil {
r.sessions[sessionID] = make(map[string]toolsrv.ToolInfo)
}
r.sessions[sessionID][name] = tool
r.revisions[sessionID]++
return nil
}
// Unload removes a tool from a session's registry.
func (r *Registry) Unload(sessionID, name string) error {
r.mu.Lock()
defer r.mu.Unlock()
sessionTools, ok := r.sessions[sessionID]
if !ok {
return nil
}
if _, exists := sessionTools[name]; !exists {
return nil
}
delete(sessionTools, name)
r.revisions[sessionID]++
return nil
}
// Loaded returns all tools loaded for a session.
func (r *Registry) Loaded(sessionID string) []toolsrv.ToolInfo {
r.mu.RLock()
defer r.mu.RUnlock()
sessionTools, ok := r.sessions[sessionID]
if !ok {
return nil
}
var loaded []toolsrv.ToolInfo
for _, info := range sessionTools {
loaded = append(loaded, info)
}
sort.Slice(loaded, func(i, j int) bool { return loaded[i].Name < loaded[j].Name })
return loaded
}
// Lookup returns a tool by name from a session's registry.
func (r *Registry) Lookup(sessionID, name string) (toolsrv.ToolInfo, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
sessionTools, ok := r.sessions[sessionID]
if !ok {
return toolsrv.ToolInfo{}, false
}
tool, exists := sessionTools[name]
return tool, exists
}
// Revision returns the revision counter for a session's registry.
func (r *Registry) Revision(sessionID string) uint64 {
r.mu.RLock()
defer r.mu.RUnlock()
return r.revisions[sessionID]
}

517
cmd/toolsrv/server.go Normal file
View File

@ -0,0 +1,517 @@
// server.go - 9P filesystem server for toolsrv.
package main
import (
"context"
"fmt"
"io"
"net"
"os"
"sync"
"ollie/fsedsl"
"9fans.net/go/plan9"
)
// fid represents an open file handle.
type fid struct {
path string
qid plan9.Qid
mode uint8
opened bool
entry fsedsl.File
readBuf []byte // buffer for read data
readDone bool // true if file has been read
dirCache []byte
}
// authFid represents an auth file handle for Tauth.
type authFid struct {
uname string
aname string
authenticated bool
token string
}
// connState holds per-connection state.
type connState struct {
mu sync.RWMutex
fids map[uint32]*fid
authFids map[uint32]*authFid
ctx context.Context
uname string
srv *Server9P
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) {
defer conn.Close()
cs := &connState{
fids: make(map[uint32]*fid),
authFids: make(map[uint32]*authFid),
ctx: ctx,
noAuth: noAuth,
srv: srv,
}
for {
select {
case <-ctx.Done():
return
default:
}
fc, err := plan9.ReadFcall(conn)
if err != nil {
if err != io.EOF {
fmt.Fprintf(os.Stderr, "toolsrv: read error: %v\n", 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)
return
}
}
}
func handleFcall(cs *connState, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall {
switch fc.Type {
case plan9.Tversion:
return handleVersion(fc)
case plan9.Tauth:
return handleAuth(cs, fc)
case plan9.Tattach:
return handleAttach(cs, fc, tree)
case plan9.Twalk:
return handleWalk(cs, fc, tree)
case plan9.Topen:
return handleOpen(cs, fc, tree)
case plan9.Tread:
return handleRead(cs, fc, tree)
case plan9.Twrite:
return handleWrite(cs, fc, tree)
case plan9.Tclunk:
return handleClunk(cs, fc)
case plan9.Tstat:
return handleStat(cs, fc, tree)
default:
return errFcall(fc, fmt.Sprintf("unsupported: %d", fc.Type))
}
}
func errFcall(fc *plan9.Fcall, msg string) *plan9.Fcall {
return &plan9.Fcall{Type: plan9.Rerror, Tag: fc.Tag, Ename: msg}
}
func handleVersion(fc *plan9.Fcall) *plan9.Fcall {
msize := fc.Msize
if msize > 65536 {
msize = 65536
}
return &plan9.Fcall{Type: plan9.Rversion, Tag: fc.Tag, Msize: msize, Version: "9P2000"}
}
func handleAuth(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Lock()
cs.authFids[fc.Afid] = &authFid{
uname: fc.Uname,
aname: fc.Aname,
}
cs.mu.Unlock()
return &plan9.Fcall{
Type: plan9.Rauth,
Tag: fc.Tag,
Aqid: plan9.Qid{Type: plan9.QTAUTH, Vers: 0, Path: uint64(fc.Afid)},
}
}
func handleAttach(cs *connState, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall {
// Skip auth check if noAuth is set (for debugging)
if cs.noAuth {
cs.mu.Lock()
cs.uname = fc.Uname
cs.fids[fc.Fid] = &fid{
path: "/",
qid: plan9.Qid{Type: plan9.QTDIR, Path: 0},
}
cs.mu.Unlock()
return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: plan9.Qid{Type: plan9.QTDIR, Path: 0}}
}
// Check auth - afid must exist and be authenticated
cs.mu.RLock()
af, hasAuth := cs.authFids[fc.Afid]
cs.mu.RUnlock()
// NOFID means no auth required (we require auth)
if fc.Afid == plan9.NOFID {
return errFcall(fc, "authentication required")
}
if !hasAuth {
return errFcall(fc, "invalid afid")
}
if !af.authenticated {
return errFcall(fc, "not authenticated")
}
cs.mu.Lock()
cs.uname = fc.Uname
cs.fids[fc.Fid] = &fid{
path: "/",
qid: plan9.Qid{Type: plan9.QTDIR, Path: 0},
}
cs.mu.Unlock()
return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: plan9.Qid{Type: plan9.QTDIR, Path: 0}}
}
// handleAuthWrite handles writing the secret to an auth fid.
func handleAuthWrite(cs *connState, fc *plan9.Fcall, af *authFid) *plan9.Fcall {
clientSecret := string(fc.Data)
token, err := cs.srv.Authenticate(clientSecret)
if err != nil {
return errFcall(fc, err.Error())
}
af.authenticated = true
af.token = token
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))}
}
// handleAuthRead handles reading the token from an auth fid.
func handleAuthRead(cs *connState, fc *plan9.Fcall, af *authFid) *plan9.Fcall {
if !af.authenticated {
return errFcall(fc, "write secret first")
}
data := []byte(af.token + "\n")
offset := int(fc.Offset)
if offset >= len(data) {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Data: nil}
}
end := offset + int(fc.Count)
if end > len(data) {
end = len(data)
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Data: data[offset:end]}
}
func handleWalk(cs *connState, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall {
cs.mu.RLock()
f, ok := cs.fids[fc.Fid]
cs.mu.RUnlock()
if !ok {
return errFcall(fc, "unknown fid")
}
newPath := f.path
var qids []plan9.Qid
for _, name := range fc.Wname {
if name == ".." {
if newPath != "/" {
lastSlash := len(newPath) - 1
for lastSlash > 0 && newPath[lastSlash] != '/' {
lastSlash--
}
if lastSlash == 0 {
newPath = "/"
} else {
newPath = newPath[:lastSlash]
}
}
} else {
if newPath == "/" {
newPath = "/" + name
} else {
newPath = newPath + "/" + name
}
}
// Check if path exists in tree
treePath := newPath
if treePath == "/" {
treePath = ""
} else {
treePath = treePath[1:] // strip leading /
}
info, err := tree.Stat(treePath)
if err != nil {
if len(qids) == 0 {
return errFcall(fc, "not found")
}
break
}
var qt uint8 = plan9.QTFILE
if info.IsDir() {
qt = plan9.QTDIR
}
qids = append(qids, plan9.Qid{Type: qt, Path: pathHash(newPath)})
}
if len(qids) == len(fc.Wname) && fc.Newfid != fc.Fid {
var lastQid plan9.Qid
if len(qids) > 0 {
lastQid = qids[len(qids)-1]
} else {
lastQid = f.qid
}
cs.mu.Lock()
cs.fids[fc.Newfid] = &fid{
path: newPath,
qid: lastQid,
}
cs.mu.Unlock()
} else if len(qids) == len(fc.Wname) && len(qids) > 0 {
cs.mu.Lock()
f.path = newPath
f.qid = qids[len(qids)-1]
cs.mu.Unlock()
}
return &plan9.Fcall{Type: plan9.Rwalk, Tag: fc.Tag, Wqid: qids}
}
func handleOpen(cs *connState, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall {
cs.mu.Lock()
defer cs.mu.Unlock()
f, ok := cs.fids[fc.Fid]
if !ok {
return errFcall(fc, "unknown fid")
}
// Directories don't need an entry
if f.qid.Type&plan9.QTDIR != 0 {
f.opened = true
f.mode = fc.Mode
return &plan9.Fcall{Type: plan9.Ropen, Tag: fc.Tag, Qid: f.qid, Iounit: 8192}
}
path := f.path
if path == "/" {
path = ""
} else {
path = path[1:] // strip leading /
}
entry, err := tree.Open(path)
if err != nil {
return errFcall(fc, err.Error())
}
f.entry = entry
f.opened = true
f.mode = fc.Mode
f.readBuf = nil
f.readDone = false
return &plan9.Fcall{Type: plan9.Ropen, Tag: fc.Tag, Qid: f.qid, Iounit: 8192}
}
func handleRead(cs *connState, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall {
// Check if reading from auth fid
cs.mu.RLock()
af, isAuthFid := cs.authFids[fc.Fid]
f, isFid := cs.fids[fc.Fid]
cs.mu.RUnlock()
if isAuthFid {
return handleAuthRead(cs, fc, af)
}
if !isFid {
return errFcall(fc, "unknown fid")
}
if !f.opened {
return errFcall(fc, "not open")
}
if f.qid.Type&plan9.QTDIR != 0 {
return handleDirRead(cs, f, fc, tree)
}
if f.entry == nil {
return errFcall(fc, "no entry")
}
// For rdwr files, check if we've already read
if f.entry.RequestRespMode() && f.readDone {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Data: nil}
}
// Buffer read data if not already done
if f.readBuf == nil {
data, err := f.entry.Read()
if err != nil && err != io.EOF {
return errFcall(fc, err.Error())
}
f.readBuf = data
}
// Handle offset/count
offset := int(fc.Offset)
if offset >= len(f.readBuf) {
f.readDone = true
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Data: nil}
}
end := offset + int(fc.Count)
if end > len(f.readBuf) {
end = len(f.readBuf)
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Data: f.readBuf[offset:end]}
}
func handleDirRead(cs *connState, f *fid, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall {
if fc.Offset == 0 || f.dirCache == nil {
path := f.path
if path == "/" {
path = ""
} else {
path = path[1:]
}
entries, err := tree.Readdir(path)
if err != nil {
return errFcall(fc, err.Error())
}
var buf []byte
for _, e := range entries {
var qt uint8 = plan9.QTFILE
mode := plan9.Perm(0644)
if e.IsDir() {
qt = plan9.QTDIR
mode = plan9.Perm(plan9.DMDIR | 0755)
}
dir := plan9.Dir{
Name: e.Name(),
Qid: plan9.Qid{Type: qt, Path: pathHash(f.path + "/" + e.Name())},
Mode: mode,
Uid: "toolsrv",
Gid: "toolsrv",
}
data, _ := dir.Bytes()
buf = append(buf, data...)
}
f.dirCache = buf
}
offset := int(fc.Offset)
if offset >= len(f.dirCache) {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Data: nil}
}
end := offset + int(fc.Count)
if end > len(f.dirCache) {
end = len(f.dirCache)
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Data: f.dirCache[offset:end]}
}
func handleWrite(cs *connState, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall {
// Check if writing to auth fid
cs.mu.Lock()
af, isAuthFid := cs.authFids[fc.Fid]
f, isFid := cs.fids[fc.Fid]
cs.mu.Unlock()
if isAuthFid {
return handleAuthWrite(cs, fc, af)
}
if !isFid {
return errFcall(fc, "unknown fid")
}
if !f.opened {
return errFcall(fc, "not open")
}
if f.entry == nil {
return errFcall(fc, "no entry")
}
err := f.entry.Write(fc.Data)
if err != nil {
return errFcall(fc, err.Error())
}
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))}
}
func handleClunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Lock()
delete(cs.fids, fc.Fid)
delete(cs.authFids, fc.Fid)
cs.mu.Unlock()
return &plan9.Fcall{Type: plan9.Rclunk, Tag: fc.Tag}
}
func handleStat(cs *connState, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall {
cs.mu.RLock()
f, ok := cs.fids[fc.Fid]
cs.mu.RUnlock()
if !ok {
return errFcall(fc, "unknown fid")
}
path := f.path
if path == "/" {
path = ""
} else {
path = path[1:]
}
info, err := tree.Stat(path)
if err != nil {
return errFcall(fc, err.Error())
}
var qt uint8 = plan9.QTFILE
mode := plan9.Perm(0644)
if info.IsDir() {
qt = plan9.QTDIR
mode = plan9.Perm(plan9.DMDIR | 0755)
}
name := info.Name()
if name == "" {
name = "/"
}
dir := plan9.Dir{
Name: name,
Qid: plan9.Qid{Type: qt, Path: pathHash(f.path)},
Mode: mode,
Uid: "toolsrv",
Gid: "toolsrv",
}
data, _ := dir.Bytes()
return &plan9.Fcall{Type: plan9.Rstat, Tag: fc.Tag, Stat: data}
}
func pathHash(p string) uint64 {
var h uint64 = 5381
for _, c := range p {
h = h*33 + uint64(c)
}
return h
}

101
cmd/toolsrv/server9p.go Normal file
View File

@ -0,0 +1,101 @@
// 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

@ -0,0 +1,134 @@
package main
import (
"testing"
)
func TestServer9P_AuthenticateFirst(t *testing.T) {
srv := NewServer9P()
secret := "test-secret-0123456789abcdef"
// First auth sets the secret and returns a token
token, err := srv.Authenticate(secret)
if err != nil {
t.Fatalf("Authenticate failed: %v", err)
}
if token == "" {
t.Fatal("Authenticate returned empty token")
}
// Token() should return the same value
if srv.Token() != token {
t.Errorf("Token() = %q, want %q", srv.Token(), token)
}
}
func TestServer9P_AuthenticateSameSecret(t *testing.T) {
srv := NewServer9P()
secret := "test-secret-0123456789abcdef"
// First auth
token1, err := srv.Authenticate(secret)
if err != nil {
t.Fatalf("First Authenticate failed: %v", err)
}
// Second auth with same secret should succeed and return same token
token2, err := srv.Authenticate(secret)
if err != nil {
t.Fatalf("Second Authenticate failed: %v", err)
}
if token1 != token2 {
t.Errorf("Token changed: %q -> %q", token1, token2)
}
}
func TestServer9P_AuthenticateWrongSecret(t *testing.T) {
srv := NewServer9P()
secret := "test-secret-0123456789abcdef"
wrongSecret := "wrong-secret-fedcba9876543210"
// First auth establishes the secret
_, err := srv.Authenticate(secret)
if err != nil {
t.Fatalf("First Authenticate failed: %v", err)
}
// Second auth with wrong secret should fail
_, err = srv.Authenticate(wrongSecret)
if err == nil {
t.Fatal("Authenticate should fail with wrong secret")
}
}
func TestServer9P_TokenBeforeAuth(t *testing.T) {
srv := NewServer9P()
// Token should be empty before authentication
if srv.Token() != "" {
t.Errorf("Token() = %q before auth, want empty", srv.Token())
}
}
func TestServer9P_BuildTree(t *testing.T) {
srv := NewServer9P()
tree := srv.BuildTree()
if tree == nil {
t.Fatal("BuildTree returned nil")
}
}
func TestServer9P_HostInfo(t *testing.T) {
srv := NewServer9P()
info := srv.HostInfo()
if info == "" {
t.Fatal("HostInfo returned empty string")
}
// Should contain platform info
if !contains(info, "platform=") {
t.Error("HostInfo missing platform=")
}
if !contains(info, "arch=") {
t.Error("HostInfo missing arch=")
}
}
func TestGenerateSecret(t *testing.T) {
s1, err := GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
if s1 == "" {
t.Fatal("GenerateSecret returned empty string")
}
// 32 bytes -> 64 hex chars
if len(s1) != 64 {
t.Errorf("GenerateSecret returned %d chars, want 64", len(s1))
}
// Two calls should return different secrets
s2, _ := GenerateSecret()
if s1 == s2 {
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
}

220
cmd/toolsrv/spec9p.go Normal file
View File

@ -0,0 +1,220 @@
// 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)
}

View File

@ -3,8 +3,7 @@
# ollie:prompt
# ## tool_load
#
# Load a tool into the current session/agent by writing its name to
# `session/{sid}/agent/{aid}/tools` via ollie-9p.
# Load a tool into the current session/agent.
#
# **Args**: `name` (required) — the tool name to load.
#
@ -15,6 +14,7 @@
set -euo pipefail
# This tool just outputs the name - the actual loading is handled by
# the agent framework when it receives the tool result.
name=$(grep -oP '"name"\s*:\s*"\K[^"]+')
echo "$name" | ollie-9p write "session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/tools"
echo "loaded: $name"
echo "STATUS=ok TOOL_LOAD=$name"

View File

@ -22,13 +22,13 @@ kf5: build-kf5 install-kf5
# === Build targets ===
build: core ninep lib9p ollie-remote lsp-tools kde
build: core ninep lib9p toolsrv lsp-tools kde
build-kf5: core ninep lib9p ollie-remote kde-kf5
build-kf5: core ninep lib9p toolsrv kde-kf5
core:
@echo === Building ollie-core ===
go build ./agent/... ./backend/... ./bypass/... ./env/... ./fs/... ./log/... ./paths/... ./prompts/... ./sandbox/... ./session/... ./tools/lsp/... ./toolsrv/...
go build ./...
ninep:
@echo === Building ollie-9p ===
@ -61,9 +61,9 @@ lsp-tools:
go build -o {{cfg}}/tools/lsp_diagnostics ./tools/lsp/cmd/lsp_diagnostics/
install -m644 ./tools/lsp/cmd/lsp_diagnostics/lsp_diagnostics.meta {{cfg}}/tools/lsp_diagnostics.meta
ollie-remote:
@echo === Building ollie-remote ===
go build -buildvcs=false -o {{bin}}/ollie-remote ./cmd/ollie-remote/
toolsrv:
@echo === Building toolsrv ===
go build -buildvcs=false -o {{bin}}/toolsrv ./cmd/toolsrv/
kde:
@echo === Building ollie-kde ===
@ -92,7 +92,7 @@ install-el:
migrate-bin:
#!/usr/bin/env sh
old="$HOME/bin"
for f in olliesrv ollie-9p ollie-remote ollie-gui ollie-remount logseq-cli o; do
for f in olliesrv ollie-9p ollie-remote toolsrv ollie-gui ollie-remount logseq-cli o; do
if [ -f "$old/$f" ]; then
echo " removing stale $old/$f"
rm -f "$old/$f"
@ -127,8 +127,8 @@ install-data:
install -m644 data/prompts/user-preferences.md {{cfg}}/prompts/user-preferences.md
# Sandbox
mkdir -p {{cfg}}/sandbox
install -m644 sandbox/default.yaml {{cfg}}/sandbox/default.yaml
install -m644 sandbox/restricted.yaml {{cfg}}/sandbox/restricted.yaml
install -m644 cmd/toolsrv/internal/sandbox/default.yaml {{cfg}}/sandbox/default.yaml
install -m644 cmd/toolsrv/internal/sandbox/restricted.yaml {{cfg}}/sandbox/restricted.yaml
# Skills
mkdir -p {{cfg}}/skills/acme-api {{cfg}}/skills/agent-kb {{cfg}}/skills/duckduckgo-search {{cfg}}/skills/github-cli {{cfg}}/skills/rc-shell \
{{cfg}}/skills/web-dev-browser-screencapture {{cfg}}/skills/write-skills
@ -175,6 +175,7 @@ install-data:
install -m755 data/tools/logseq {{cfg}}/tools/logseq
install -m755 data/tools/skill_list {{cfg}}/tools/skill_list
install -m755 data/tools/skill_load {{cfg}}/tools/skill_load
install -m755 data/tools/tool_load {{cfg}}/tools/tool_load
install -m755 data/tools/system_logs_dmesg {{cfg}}/tools/system_logs_dmesg
install -m755 data/tools/system_logs_journald {{cfg}}/tools/system_logs_journald
# Tool metadata (.meta sidecar files)
@ -252,20 +253,20 @@ test: test-core test-9p
test-core:
@echo === Testing ollie-core ===
go test ./agent/... ./backend/... ./bypass/... ./env/... ./log/... ./paths/... ./sandbox/... ./session/... ./toolsrv/... ./prompts/...
go test ./cmd/olliesrv/... ./cmd/toolsrv/... ./env/... ./log/... ./paths/... ./toolsrv/...
test-9p:
@echo === Testing ollie-9p ===
go test ./fs/...
go test ./lib9p/...
test-remote:
@echo === Testing ollie-remote ===
go build ./cmd/ollie-remote/...
@echo === Testing toolsrv ===
go build ./cmd/toolsrv/...
# === Clean ===
clean:
go clean ./agent/... ./backend/... 2>/dev/null || true
go clean ./cmd/... 2>/dev/null || true
rm -f {{bin}}/Ollie {{bin}}/Kmpl {{bin}}/Oi {{bin}}/Theo
rm -f {{bin}}/ollie-remote
just -f kde/justfile clean

468
toolsrv/client9p.go Normal file
View File

@ -0,0 +1,468 @@
// client9p.go - 9P client for toolsrv.
package toolsrv
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"syscall"
"9fans.net/go/plan9"
p9client "9fans.net/go/plan9/client"
)
// Conn is a 9P connection to a toolsrv.
type Conn struct {
fsys *p9client.Fsys
conn *p9client.Conn
token string // session token from auth
secret string // secret used for auth (for reconnect)
onToolsChanged func() // callback for tool changes (client must poll)
}
// Dial connects to a toolsrv at the given Unix socket path and authenticates.
// If secret is empty, a random one is generated (first connection).
// Returns the connection and the secret used (caller should save for reconnect).
func Dial(socketPath string, secret string) (*Conn, error) {
conn, err := p9client.Dial("unix", socketPath)
if err != nil {
return nil, err
}
// Generate secret if not provided
if secret == "" {
secret = randomSecret()
}
// Authenticate via Tauth
afid, err := conn.Auth("agent", "")
if err != nil {
conn.Close()
return nil, fmt.Errorf("auth: %w", err)
}
// Write secret to auth fid
if _, err := afid.Write([]byte(secret)); err != nil {
afid.Close()
conn.Close()
return nil, fmt.Errorf("write secret: %w", err)
}
// Read token from auth fid (use ReadAt to read from offset 0,
// since Write advances the file offset)
tokenBuf := make([]byte, 1024)
n, err := afid.ReadAt(tokenBuf, 0)
if err != nil && err != io.EOF {
afid.Close()
conn.Close()
return nil, fmt.Errorf("read token: %w", err)
}
token := string(tokenBuf[:n])
// Trim newline
if len(token) > 0 && token[len(token)-1] == '\n' {
token = token[:len(token)-1]
}
// Attach using the authenticated afid
fsys, err := conn.Attach(afid, "agent", "")
if err != nil {
afid.Close()
conn.Close()
return nil, fmt.Errorf("attach: %w", err)
}
afid.Close()
return &Conn{
fsys: fsys,
conn: conn,
token: token,
secret: secret,
}, nil
}
// Secret returns the secret used for authentication.
// Save this for reconnecting to the same toolsrv instance.
func (c *Conn) Secret() string {
return c.secret
}
// Token returns the session token.
func (c *Conn) Token() string {
return c.token
}
// ListTools returns the list of loaded tools.
func (c *Conn) ListTools() ([]ToolInfo, error) {
fid, err := c.fsys.Open("tools", plan9.OREAD)
if err != nil {
return nil, err
}
defer fid.Close()
data, err := io.ReadAll(fid)
if err != nil {
return nil, err
}
var tools []ToolInfo
if err := json.Unmarshal(data, &tools); err != nil {
return nil, fmt.Errorf("parse tools: %w", err)
}
return tools, nil
}
// CallTool executes a tool and returns the result.
func (c *Conn) CallTool(ctx context.Context, name string, args json.RawMessage) (json.RawMessage, error) {
fid, err := c.fsys.Open("proc/new", plan9.ORDWR)
if err != nil {
return nil, fmt.Errorf("open proc/new: %w", err)
}
defer fid.Close()
// Convert JSON args to key=value format
var argMap map[string]interface{}
if err := json.Unmarshal(args, &argMap); err != nil {
argMap = make(map[string]interface{})
}
payload := fmt.Sprintf("token=%s\ntool=%s\n", c.token, name)
for k, v := range argMap {
escaped := escapeValue(fmt.Sprintf("%v", v))
payload += fmt.Sprintf("%s=%s\n", k, escaped)
}
if _, err := fid.Write([]byte(payload)); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
// Read result from offset 0 (Write advances the file offset, but
// for request-response files we need to read the result from the start)
var result []byte
buf := make([]byte, 8192)
for offset := int64(0); ; {
n, err := fid.ReadAt(buf, offset)
if n > 0 {
result = append(result, buf[:n]...)
offset += int64(n)
}
if err == io.EOF || n == 0 {
break
}
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
}
return json.RawMessage(result), nil
}
// LoadTool loads a tool by name.
func (c *Conn) LoadTool(name string) error {
fid, err := c.fsys.Open("ctl", plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
_, err = fid.Write([]byte("load " + name + "\n"))
return err
}
// UnloadTool unloads a tool by name.
func (c *Conn) UnloadTool(name string) error {
fid, err := c.fsys.Open("ctl", plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
_, err = fid.Write([]byte("unload " + name + "\n"))
return err
}
// Ping checks if the connection is alive.
func (c *Conn) Ping() error {
_, err := c.fsys.Stat("info")
return err
}
// Close closes the connection.
func (c *Conn) Close() {
if c.conn != nil {
c.conn.Close()
}
}
// HostInfo returns host information from the server.
func (c *Conn) HostInfo() (HostInfo, error) {
fid, err := c.fsys.Open("info", plan9.OREAD)
if err != nil {
return HostInfo{}, err
}
defer fid.Close()
data, err := io.ReadAll(fid)
if err != nil {
return HostInfo{}, err
}
info := HostInfo{}
for _, line := range splitLines(string(data)) {
if idx := indexOf(line, '='); idx >= 0 {
key := line[:idx]
val := line[idx+1:]
switch key {
case "platform":
info.Platform = val
case "git":
info.IsGitRepo = val == "true"
}
}
}
return info, nil
}
// --- Environment and Configuration ---
// SetEnv sets an environment variable via ctl command.
func (c *Conn) SetEnv(key, value string) error {
fid, err := c.fsys.Open("ctl", plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
_, err = fid.Write([]byte(fmt.Sprintf("env %s=%s\n", key, value)))
return err
}
// SetCWD sets the working directory via ctl command.
func (c *Conn) SetCWD(dir string) error {
fid, err := c.fsys.Open("ctl", plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
_, err = fid.Write([]byte(fmt.Sprintf("cwd %s\n", dir)))
return err
}
// SetOnToolsChanged stores a callback that is invoked when tools change.
// In 9P model, this is handled by polling ToolRegistryRevision().
// The callback is stored but you must call CheckToolsChanged() periodically.
func (c *Conn) SetOnToolsChanged(fn func()) {
c.onToolsChanged = fn
}
// ToolRegistryRevision returns the current tool registry revision.
// Returns 0 if not available (always refresh).
func (c *Conn) ToolRegistryRevision() uint64 {
fid, err := c.fsys.Open("info", plan9.OREAD)
if err != nil {
return 0
}
defer fid.Close()
data, err := io.ReadAll(fid)
if err != nil {
return 0
}
for _, line := range splitLines(string(data)) {
if idx := indexOf(line, '='); idx >= 0 {
if line[:idx] == "tools_rev" {
var rev uint64
fmt.Sscanf(line[idx+1:], "%d", &rev)
return rev
}
}
}
return 0
}
// --- Process management ---
// Detach detaches the current foreground process to background.
// Returns true if successful.
func (c *Conn) Detach() bool {
fid, err := c.fsys.Open("ctl", plan9.OWRITE)
if err != nil {
return false
}
defer fid.Close()
_, err = fid.Write([]byte("detach\n"))
return err == nil
}
// ListDetachedRaw returns raw info about detached processes.
func (c *Conn) ListDetachedRaw() []any {
fid, err := c.fsys.Open("proc", plan9.OREAD)
if err != nil {
return nil
}
defer fid.Close()
var result []any
// Read directory entries
for {
dirs, err := fid.Dirread()
if err != nil || len(dirs) == 0 {
break
}
for _, d := range dirs {
if d.Name == "new" || d.Name == "new.bg" {
continue
}
info := c.readProcInfo(d.Name)
if info != nil {
result = append(result, info)
}
}
}
return result
}
// readProcInfo reads info about a single proc.
func (c *Conn) readProcInfo(name string) map[string]any {
path := fmt.Sprintf("proc/%s/stat", name)
fid, err := c.fsys.Open(path, plan9.OREAD)
if err != nil {
return nil
}
defer fid.Close()
data, err := io.ReadAll(fid)
if err != nil {
return nil
}
info := make(map[string]any)
for _, line := range splitLines(string(data)) {
if idx := indexOf(line, '='); idx >= 0 {
key := line[:idx]
val := line[idx+1:]
switch key {
case "pid":
var pid int
fmt.Sscanf(val, "%d", &pid)
info["pid"] = pid
case "command":
info["command"] = val
case "started":
var ts int64
fmt.Sscanf(val, "%d", &ts)
info["started"] = ts
case "exited":
info["exited"] = val == "true"
case "exit_code":
var code int
fmt.Sscanf(val, "%d", &code)
info["exit_code"] = code
}
}
}
return info
}
// SignalDetached sends a signal to a detached process.
func (c *Conn) SignalDetached(pid int, sig syscall.Signal) error {
path := fmt.Sprintf("proc/%d/ctl", pid)
fid, err := c.fsys.Open(path, plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
_, err = fid.Write([]byte(fmt.Sprintf("signal %d\n", sig)))
return err
}
// DismissDetached dismisses a detached process.
func (c *Conn) DismissDetached(pid int) bool {
path := fmt.Sprintf("proc/%d/ctl", pid)
fid, err := c.fsys.Open(path, plan9.OWRITE)
if err != nil {
return false
}
defer fid.Close()
_, err = fid.Write([]byte("dismiss\n"))
return err == nil
}
// GetDetachedOutput gets output from a detached process.
func (c *Conn) GetDetachedOutput(pid int) (string, error) {
path := fmt.Sprintf("proc/%d/out", pid)
fid, err := c.fsys.Open(path, plan9.OREAD)
if err != nil {
return "", err
}
defer fid.Close()
data, err := io.ReadAll(fid)
return string(data), err
}
// --- Helper types ---
// HostInfo contains information about the host running toolsrv.
type HostInfo struct {
Platform string
IsGitRepo bool
}
// RateLimitedError indicates a rate limit was hit.
type RateLimitedError struct {
Err error
Remaining int
}
func (e RateLimitedError) Error() string {
return e.Err.Error()
}
// --- Helpers ---
func randomSecret() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
func indexOf(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}
func escapeValue(s string) string {
// Escape newlines and backslashes
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
switch s[i] {
case '\n':
result = append(result, '\\', 'n')
case '\\':
result = append(result, '\\', '\\')
default:
result = append(result, s[i])
}
}
return string(result)
}

View File

@ -1,304 +0,0 @@
package toolsrv
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"sync"
"sync/atomic"
"syscall"
)
// Conn is a unified JSON-RPC client connected to a tool server subprocess
// (ollie-remote). It satisfies Runner and all extended interfaces that the
// agent layer uses via type assertions. Both local and remote connections
// return the same *Conn — the only difference is the underlying transport.
type Conn struct {
writeMu sync.Mutex
pendingMu sync.Mutex
pending map[int64]*pendingCall
closed chan struct{}
closeOnce sync.Once
readErr error
enc *json.Encoder
dec *json.Decoder
nextID atomic.Int64
Info HostInfo
// cleanup is called on Close (kills subprocess, closes SSH, etc.)
cleanup func()
// onToolsChanged is a callback for tool directory change notifications.
onToolsChanged func()
}
// NewConn wraps an io.ReadWriteCloser as a JSON-RPC connection.
// The cleanup function is called when Close() is invoked.
func NewConn(rwc io.ReadWriteCloser, cleanup func()) *Conn {
c := &Conn{
enc: json.NewEncoder(rwc),
dec: json.NewDecoder(bufio.NewReader(rwc)),
cleanup: cleanup,
pending: make(map[int64]*pendingCall),
closed: make(chan struct{}),
}
go c.readLoop()
return c
}
// NewConnSplit creates a Conn from separate reader/writer (e.g. stdin/stdout pipes).
func NewConnSplit(r io.Reader, w io.WriteCloser, cleanup func()) *Conn {
c := &Conn{
enc: json.NewEncoder(w),
dec: json.NewDecoder(bufio.NewReader(r)),
cleanup: cleanup,
pending: make(map[int64]*pendingCall),
closed: make(chan struct{}),
}
go c.readLoop()
return c
}
// --- Runner interface ---
func (c *Conn) ListTools() ([]ToolInfo, error) {
resp, err := c.call("list_tools", nil)
if err != nil {
return nil, err
}
var infos []ToolInfo
if err := json.Unmarshal(resp, &infos); err != nil {
return nil, fmt.Errorf("list_tools unmarshal: %w", err)
}
return infos, nil
}
func (c *Conn) CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
return c.callStreaming(ctx, tool, args)
}
// --- Extended interfaces (used by agent via type assertion) ---
func (c *Conn) SetEnv(key, value string) {
params, _ := json.Marshal(map[string]string{"key": key, "value": value})
c.call("set_env", params)
}
func (c *Conn) SetCWD(dir string) {
params, _ := json.Marshal(map[string]string{"dir": dir})
c.call("set_cwd", params)
}
func (c *Conn) SetOnToolsChanged(fn func()) {
c.pendingMu.Lock()
c.onToolsChanged = fn
c.pendingMu.Unlock()
}
// ToolRegistryRevision returns 0 for remote connections (revision tracking
// is not supported over RPC; use SetOnToolsChanged for push-based refresh).
func (c *Conn) ToolRegistryRevision() uint64 { return 0 }
func (c *Conn) Detach() bool {
resp, err := c.call("detach", nil)
if err != nil {
return false
}
var ok bool
json.Unmarshal(resp, &ok)
return ok
}
func (c *Conn) ListDetachedRaw() []any {
resp, err := c.call("list_detached", nil)
if err != nil {
return nil
}
var out []any
json.Unmarshal(resp, &out)
return out
}
func (c *Conn) SignalDetached(pid int, sig syscall.Signal) error {
params, _ := json.Marshal(map[string]any{"pid": pid, "signal": int(sig)})
_, err := c.call("signal_detached", params)
return err
}
func (c *Conn) GetDetachedOutput(pid int) (string, error) {
params, _ := json.Marshal(map[string]any{"pid": pid})
resp, err := c.call("get_detached_output", params)
if err != nil {
return "", err
}
var out string
json.Unmarshal(resp, &out)
return out, nil
}
func (c *Conn) DismissDetached(pid int) bool {
params, _ := json.Marshal(map[string]any{"pid": pid})
resp, err := c.call("dismiss_detached", params)
if err != nil {
return false
}
var ok bool
json.Unmarshal(resp, &ok)
return ok
}
// Close shuts down the connection and subprocess.
func (c *Conn) Close() {
c.fail(fmt.Errorf("connection closed"))
if c.cleanup != nil {
c.cleanup()
}
}
// Ping verifies the remote server is responsive.
func (c *Conn) Ping() error {
_, err := c.call("ping", nil)
return err
}
// FetchHostInfo retrieves environment details from the server.
func (c *Conn) FetchHostInfo() (HostInfo, error) {
resp, err := c.call("host_info", nil)
if err != nil {
return HostInfo{}, err
}
var info HostInfo
if err := json.Unmarshal(resp, &info); err != nil {
return HostInfo{}, err
}
return info, nil
}
type pendingCall struct {
response chan rpcResponse
ctx context.Context
}
func (c *Conn) readLoop() {
for {
var resp rpcResponse
if err := c.dec.Decode(&resp); err != nil {
c.fail(fmt.Errorf("rpc read: %w", err))
return
}
c.pendingMu.Lock()
call := c.pending[resp.ID]
c.pendingMu.Unlock()
if call == nil {
continue
}
// Tool output is sent as an intermediate response with the request ID.
var notif outputNotification
if resp.Stream && resp.Error == nil && json.Unmarshal(resp.Result, &notif) == nil {
if notif.Data != "" {
StreamOutput(call.ctx, notif.Data)
}
continue
}
c.pendingMu.Lock()
delete(c.pending, resp.ID)
c.pendingMu.Unlock()
call.response <- resp
}
}
func (c *Conn) fail(err error) {
c.closeOnce.Do(func() {
c.pendingMu.Lock()
c.readErr = err
close(c.closed)
for id, call := range c.pending {
delete(c.pending, id)
call.response <- rpcResponse{Error: &rpcError{Code: -32000, Message: err.Error()}}
}
c.pendingMu.Unlock()
})
}
// --- JSON-RPC internals ---
func (c *Conn) call(method string, params json.RawMessage) (json.RawMessage, error) {
return c.callStreaming(context.Background(), method, params)
}
func (c *Conn) callStreaming(ctx context.Context, method string, params json.RawMessage) (json.RawMessage, error) {
id := c.nextID.Add(1)
call := &pendingCall{response: make(chan rpcResponse, 1), ctx: ctx}
c.pendingMu.Lock()
select {
case <-c.closed:
c.pendingMu.Unlock()
return nil, fmt.Errorf("rpc %s: connection closed", method)
default:
}
c.pending[id] = call
c.pendingMu.Unlock()
c.writeMu.Lock()
err := c.enc.Encode(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params})
c.writeMu.Unlock()
if err != nil {
c.pendingMu.Lock()
delete(c.pending, id)
c.pendingMu.Unlock()
return nil, fmt.Errorf("rpc write %s: %w", method, err)
}
select {
case resp := <-call.response:
if resp.Error != nil {
return nil, fmt.Errorf("rpc %s: %s", method, resp.Error.Message)
}
return resp.Result, nil
case <-ctx.Done():
c.pendingMu.Lock()
delete(c.pending, id)
c.pendingMu.Unlock()
return nil, ctx.Err()
case <-c.closed:
c.pendingMu.Lock()
err := c.readErr
c.pendingMu.Unlock()
if err == nil {
err = fmt.Errorf("connection closed")
}
return nil, fmt.Errorf("rpc %s: %w", method, err)
}
}
// JSON-RPC 2.0 types shared between Conn (client) and transport layer.
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
Stream bool `json:"stream,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type outputNotification struct {
Data string `json:"data"`
}

View File

@ -1,99 +0,0 @@
package toolsrv
import (
"sync"
"time"
)
const RingBufSize = 64 * 1024 // 64KB ring buffer per detached process
// DetachedProcess represents a process that the agent has detached from
// but which continues running. The user can view its output and signal it.
type DetachedProcess struct {
PID int
Command string
Started time.Time
Exited bool
ExitCode int
Ring *RingBuffer
Done chan struct{}
Mu sync.Mutex
}
// Info returns a plain-data snapshot of this process for external consumers.
type InfoData struct {
PID int
Command string
Started int64 // unix timestamp
Exited bool
ExitCode int
}
func (p *DetachedProcess) Info() InfoData {
p.Mu.Lock()
defer p.Mu.Unlock()
return InfoData{
PID: p.PID,
Command: p.Command,
Started: p.Started.Unix(),
Exited: p.Exited,
ExitCode: p.ExitCode,
}
}
// Output returns the current contents of the ring buffer.
func (p *DetachedProcess) Output() string {
p.Mu.Lock()
defer p.Mu.Unlock()
return p.Ring.String()
}
// ringBuffer is a fixed-size circular byte buffer.
type RingBuffer struct {
buf []byte
size int
pos int
full bool
}
func NewRingBuffer(size int) *RingBuffer {
return &RingBuffer{buf: make([]byte, size), size: size}
}
// Write implements io.Writer.
func (r *RingBuffer) Write(p []byte) (int, error) {
n := len(p)
if n >= r.size {
// Data larger than buffer: just keep the tail
copy(r.buf, p[n-r.size:])
r.pos = 0
r.full = true
return n, nil
}
if r.pos+n <= r.size {
copy(r.buf[r.pos:], p)
} else {
first := r.size - r.pos
copy(r.buf[r.pos:], p[:first])
copy(r.buf, p[first:])
r.full = true
}
r.pos = (r.pos + n) % r.size
if r.pos == 0 && n > 0 {
r.full = true
}
return n, nil
}
// String returns the buffer contents in order.
func (r *RingBuffer) String() string {
if !r.full {
return string(r.buf[:r.pos])
}
// Buffer has wrapped: data from pos..end + 0..pos
out := make([]byte, r.size)
copy(out, r.buf[r.pos:])
copy(out[r.size-r.pos:], r.buf[:r.pos])
return string(out)
}

View File

@ -1,25 +0,0 @@
package toolsrv
import (
"context"
"fmt"
"net"
)
// Dial connects to a running tool server at the given Unix socket path.
// The server must already be running (started via Spawn or SpawnRemote).
func Dial(ctx context.Context, socket string) (*Conn, error) {
if socket == "" {
return nil, fmt.Errorf("toolsrv.Dial: socket path required")
}
conn, err := net.Dial("unix", socket)
if err != nil {
return nil, fmt.Errorf("toolsrv.Dial: %w", err)
}
c := NewConn(conn, func() { conn.Close() })
if err := c.Ping(); err != nil {
c.Close()
return nil, fmt.Errorf("toolsrv.Dial: ping failed: %w", err)
}
return c, nil
}

View File

@ -1,594 +0,0 @@
package toolsrv
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"ollie/paths"
"ollie/sandbox"
)
func loadSandboxConfig(name string) (*sandbox.Config, error) {
if name == "" {
name = "default"
}
path := filepath.Join(paths.CfgDir(), "sandbox", name+".yaml")
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("sandbox %q not found: %w", name, err)
}
defer f.Close()
return sandbox.LoadSandbox(f)
}
type limitedWriter struct {
mu sync.Mutex
w io.Writer
written int
limit int
truncated bool
stream func(string) // if non-nil, called with each chunk of output
}
func (lw *limitedWriter) Write(p []byte) (n int, err error) {
lw.mu.Lock()
defer lw.mu.Unlock()
if lw.written >= lw.limit {
lw.truncated = true
return len(p), nil
}
remaining := lw.limit - lw.written
toWrite := p
if len(p) > remaining {
toWrite = p[:remaining]
lw.truncated = true
}
written, err := lw.w.Write(toWrite)
lw.written += written
if lw.stream != nil {
lw.stream(string(toWrite[:written]))
}
if err != nil {
return written, err
}
return len(p), nil
}
// Connects to the broker socket, sends the request with the current env,
// and streams the framed response back.
func (s *Server) executeBypass(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) {
return s.executeBypassOpts(ctx, cmd, dir, timeout, false, doDetach...)
}
func (s *Server) executeBypassSudo(ctx context.Context, cmd, dir string, timeout int) (string, error) {
return s.executeBypassOpts(ctx, cmd, dir, timeout, true)
}
func (s *Server) executeBypassOpts(ctx context.Context, cmd, dir string, timeout int, sudo bool, doDetach ...bool) (string, error) {
xdg := os.Getenv("XDG_RUNTIME_DIR")
if xdg == "" {
return "", fmt.Errorf("bypass not available: no XDG_RUNTIME_DIR")
}
sockPath := filepath.Join(xdg, "ollie", "bypass.sock")
wantDetach := len(doDetach) > 0 && doDetach[0]
var cancel context.CancelFunc
if wantDetach || timeout <= 0 {
ctx, cancel = context.WithCancel(ctx)
} else {
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
}
// Connect to broker
conn, err := net.DialTimeout("unix", sockPath, 5*time.Second)
if err != nil {
cancel()
return "", fmt.Errorf("bypass not available: %w", err)
}
// Send request — merge process env with session-scoped envExtra.
envMap := make(map[string]string, len(os.Environ()))
for _, kv := range os.Environ() {
if k, v, ok := strings.Cut(kv, "="); ok {
envMap[k] = v
}
}
s.envMu.RLock()
for k, v := range s.envExtra {
envMap[k] = v
}
s.envMu.RUnlock()
reqJSON, _ := json.Marshal(struct {
Cmd string `json:"cmd"`
Cwd string `json:"cwd"`
Env map[string]string `json:"env"`
Session string `json:"session,omitempty"`
Sudo bool `json:"sudo,omitempty"`
}{Cmd: cmd, Cwd: dir, Env: envMap, Session: s.sessionID, Sudo: sudo})
reqJSON = append(reqJSON, '\n')
if _, err := conn.Write(reqJSON); err != nil {
conn.Close()
cancel()
return "", fmt.Errorf("bypass execution failed: write: %w", err)
}
// Set up detach channel
detachCh := make(chan struct{}, 1)
s.detachMu.Lock()
s.detachCh = detachCh
s.detachMu.Unlock()
defer func() {
s.detachMu.Lock()
s.detachCh = nil
s.detachMu.Unlock()
}()
if wantDetach {
close(detachCh)
}
// readFrames reads from the connection, writing to w and streaming.
// Returns exit code when 'x' frame arrives or -1 on error.
readFrames := func(w io.Writer, stream func(string)) int {
header := make([]byte, 5)
for {
conn.SetReadDeadline(time.Now().Add(1 * time.Second)) //nolint:errcheck
_, err := io.ReadFull(conn, header)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
// Check for context cancellation
select {
case <-ctx.Done():
return -1
default:
continue
}
}
return -1
}
frameType := header[0]
length := binary.BigEndian.Uint32(header[1:5])
payload := make([]byte, length)
if length > 0 {
conn.SetReadDeadline(time.Now().Add(30 * time.Second)) //nolint:errcheck
if _, err := io.ReadFull(conn, payload); err != nil {
return -1
}
}
switch frameType {
case 'd':
w.Write(payload) //nolint:errcheck
if stream != nil {
stream(string(payload))
}
case 'x':
var code int
fmt.Sscanf(string(payload), "%d", &code)
return code
}
}
}
// Check if detach was requested
select {
case <-detachCh:
// Detach: background the socket read into a goroutine
cmdStr := "bypass: " + cmd
if len(cmdStr) > 80 {
cmdStr = cmdStr[:77] + "..."
}
ring := NewRingBuffer(RingBufSize)
pid := int(time.Now().UnixNano() & 0x7FFFFFFF) // synthetic PID
proc := &DetachedProcess{
PID: pid,
Command: cmdStr,
Started: time.Now(),
Ring: ring,
Done: make(chan struct{}),
}
s.detachMu.Lock()
s.detached = append(s.detached, proc)
s.detachMu.Unlock()
go func() {
defer conn.Close()
defer cancel()
exitCode := readFrames(ring, nil)
proc.Mu.Lock()
proc.Exited = true
proc.ExitCode = exitCode
proc.Mu.Unlock()
close(proc.Done)
if s.OnExit != nil {
s.OnExit(proc.PID, proc.ExitCode)
}
}()
if s.OnDetach != nil {
s.OnDetach(proc.PID, cmdStr)
}
return fmt.Sprintf("[detached: pid %d]", pid), nil
default:
// Normal (foreground) execution — must also handle manual detach.
var outputBuf bytes.Buffer
streamFn := StreamFunc(ctx)
lw := &limitedWriter{
w: &outputBuf,
limit: 10 * 1024 * 1024,
stream: streamFn,
}
// Run readFrames in a goroutine so we can select on detach.
type frameResult struct{ exitCode int }
frameCh := make(chan frameResult, 1)
go func() {
code := readFrames(lw, nil)
frameCh <- frameResult{code}
}()
select {
case fr := <-frameCh:
// Normal completion
conn.Close()
cancel()
combined := outputBuf.String()
if fr.exitCode != 0 {
if combined == "" {
return "", fmt.Errorf("bypass execution failed (exit %d)", fr.exitCode)
}
errOutput := combined
const maxErrOutput = 8192
if len(errOutput) > maxErrOutput {
errOutput = errOutput[:maxErrOutput] + fmt.Sprintf("\n[...truncated %d bytes in error]", len(combined)-maxErrOutput)
}
return combined, fmt.Errorf("bypass execution failed (exit %d)\nOutput: %s", fr.exitCode, errOutput)
}
return combined, nil
case <-ctx.Done():
// Timeout or external cancellation
conn.Close()
cancel()
combined := outputBuf.String()
if ctx.Err() == context.DeadlineExceeded {
return combined, fmt.Errorf("bypass execution timeout after %d seconds", timeout)
}
return combined, fmt.Errorf("bypass execution interrupted")
case <-detachCh:
// Manual detach: background the connection read
cmdStr := "bypass: " + cmd
if len(cmdStr) > 80 {
cmdStr = cmdStr[:77] + "..."
}
ring := NewRingBuffer(RingBufSize)
pid := int(time.Now().UnixNano() & 0x7FFFFFFF)
// Splice: future output goes to ring buffer, stop streaming
lw.mu.Lock()
lw.w = ring
lw.stream = nil
lw.mu.Unlock()
proc := &DetachedProcess{
PID: pid,
Command: cmdStr,
Started: time.Now(),
Ring: ring,
Done: make(chan struct{}),
}
s.detachMu.Lock()
s.detached = append(s.detached, proc)
s.detachMu.Unlock()
go func() {
fr := <-frameCh
conn.Close()
cancel()
proc.Mu.Lock()
proc.Exited = true
proc.ExitCode = fr.exitCode
proc.Mu.Unlock()
close(proc.Done)
if s.OnExit != nil {
s.OnExit(proc.PID, proc.ExitCode)
}
}()
if s.OnDetach != nil {
s.OnDetach(proc.PID, cmdStr)
}
partial := outputBuf.String()
return partial + fmt.Sprintf("\n[detached: pid %d]", pid), nil
}
}
}
// executeWithStdin runs code in a sandbox and returns combined stdout+stderr.
// For languages where code is itself passed via stdin (ed, expect, bc), stdinData is ignored.
func (s *Server) executeWithStdin(ctx context.Context, code, language string, timeout int, sandboxName string, trusted bool, stdinData string, doDetach ...bool) (string, error) {
if timeout < 0 {
timeout = 30
}
if !trusted {
if err := s.ValidateCode(code, language); err != nil {
return "", err
}
}
var cfg *sandbox.Config
var err error
if !s.Yolo {
cfg, err = loadSandboxConfig(sandboxName)
if err != nil {
return "", err
}
}
s.wdMu.RLock()
workDir := s.cwd
s.wdMu.RUnlock()
if workDir == "" {
workDir, _ = os.Getwd()
}
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
} else {
// timeout=0 means no timeout (run indefinitely, e.g. for daemons)
ctx, cancel = context.WithCancel(ctx)
}
// NOTE: do NOT defer cancel() here — the detach path must avoid cancelling
// the context (which would kill the detached process via cmd.Cancel).
// Each exit path calls cancel() explicitly except detach.
var cmd *exec.Cmd
var interpreter []string
// codeStdin: non-empty means the code itself is fed via stdin (ed, expect, bc).
// In these cases stdinData cannot be used simultaneously.
var codeStdin string
switch language {
case "bash", "":
interpreter = []string{"bash", "-c", code}
default:
cancel()
return "", fmt.Errorf("unsupported language: %s (only bash is supported)", language)
}
s.envMu.RLock()
envMap := make(map[string]string, len(s.envExtra))
for k, v := range s.envExtra {
envMap[k] = v
}
s.envMu.RUnlock()
for _, ev := range os.Environ() {
k, v, _ := strings.Cut(ev, "=")
if _, ok := envMap[k]; !ok {
envMap[k] = v
}
}
if _, ok := envMap["NAMESPACE"]; !ok {
if ns := plan9Namespace(envMap); ns != "" {
envMap["NAMESPACE"] = ns
}
}
getenv := func(key string) string { return envMap[key] }
if s.Yolo {
cmd = exec.CommandContext(ctx, interpreter[0], interpreter[1:]...)
} else {
wrapped, wrapErr := sandbox.WrapCommand(cfg, interpreter, workDir, getenv)
if wrapErr != nil {
cancel()
return "", wrapErr
}
cmd = exec.CommandContext(ctx, wrapped[0], wrapped[1:]...)
}
cmd.Dir = workDir
switch {
case codeStdin != "":
cmd.Stdin = strings.NewReader(codeStdin)
case stdinData != "":
cmd.Stdin = strings.NewReader(stdinData)
}
s.envMu.RLock()
baseEnv := os.Environ()
// Remove keys that envExtra overrides so duplicates don't leak through.
filtered := baseEnv[:0]
for _, ev := range baseEnv {
k, _, _ := strings.Cut(ev, "=")
if _, overridden := s.envExtra[k]; !overridden {
filtered = append(filtered, ev)
}
}
cmd.Env = filtered
for k, v := range s.envExtra {
cmd.Env = append(cmd.Env, k+"="+v)
}
s.envMu.RUnlock()
// Export computed NAMESPACE so tools like ollie-9p can find the server.
if ns := envMap["NAMESPACE"]; ns != "" {
cmd.Env = append(cmd.Env, "NAMESPACE="+ns)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = time.Second
var outputBuf bytes.Buffer
lw := &limitedWriter{
w: &outputBuf,
limit: 10 * 1024 * 1024,
stream: StreamFunc(ctx),
}
cmd.Stdout = lw
cmd.Stderr = lw
// Set up detach channel for this execution
detachCh := make(chan struct{}, 1)
s.detachMu.Lock()
s.detachCh = detachCh
s.detachMu.Unlock()
defer func() {
s.detachMu.Lock()
s.detachCh = nil
s.detachMu.Unlock()
}()
if err = cmd.Start(); err != nil {
cancel()
return "", fmt.Errorf("execution failed: %w", err)
}
// If detach requested, immediately signal the detach channel
if len(doDetach) > 0 && doDetach[0] {
close(detachCh)
}
// Wait for completion, context cancellation, or detach signal
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
select {
case err = <-waitCh:
// Normal completion
cancel()
output := outputBuf.Bytes()
if lw.truncated {
output = append(output, []byte("\n[output truncated at 10MB]")...)
}
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("execution timeout after %d seconds", timeout)
}
if err != nil {
// Cap output in error message to avoid flooding the agent context.
// The full output is still returned as the first return value.
errOutput := string(output)
const maxErrOutput = 8192
if len(errOutput) > maxErrOutput {
errOutput = errOutput[:maxErrOutput] + fmt.Sprintf("\n[...truncated %d bytes in error]", len(output)-maxErrOutput)
}
return string(output), fmt.Errorf("execution failed: %w\nOutput: %s", err, errOutput)
}
return string(output), nil
case <-ctx.Done():
// Context cancelled (interrupt or timeout)
cancel()
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-waitCh // reap
output := outputBuf.Bytes()
if ctx.Err() == context.DeadlineExceeded {
return string(output), fmt.Errorf("execution timeout after %d seconds", timeout)
}
return string(output), fmt.Errorf("execution interrupted")
case <-detachCh:
// Detach: move process to registry, continue running
// Neutralize cmd.Cancel and WaitDelay so context cancellation
// won't kill the process after the timeout fires.
cmd.Cancel = nil
cmd.WaitDelay = 0
cancel()
cmdStr := strings.Join(interpreter, " ")
if len(cmdStr) > 80 {
cmdStr = cmdStr[:77] + "..."
}
ring := NewRingBuffer(RingBufSize)
// Splice: future output goes to ring buffer instead of outputBuf.
lw.mu.Lock()
lw.w = ring
lw.stream = nil
lw.mu.Unlock()
proc := &DetachedProcess{
PID: cmd.Process.Pid,
Command: cmdStr,
Started: time.Now(),
Ring: ring,
Done: make(chan struct{}),
}
s.detachMu.Lock()
s.detached = append(s.detached, proc)
s.detachMu.Unlock()
// Monitor for exit in background
go func() {
waitErr := <-waitCh
proc.Mu.Lock()
proc.Exited = true
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
proc.ExitCode = exitErr.ExitCode()
} else {
proc.ExitCode = -1
}
}
proc.Mu.Unlock()
close(proc.Done)
if s.OnExit != nil {
s.OnExit(proc.PID, proc.ExitCode)
}
}()
if s.OnDetach != nil {
s.OnDetach(proc.PID, cmdStr)
}
partial := outputBuf.String()
return partial + fmt.Sprintf("\n[detached: pid %d]", proc.PID), nil
}
}
// plan9Namespace computes the plan9port namespace directory from the environment.
// Convention: $NAMESPACE if set, else /tmp/ns.<user>.<display> where display is
// $DISPLAY with trailing .0 stripped and / replaced by _.
func plan9Namespace(env map[string]string) string {
if ns := env["NAMESPACE"]; ns != "" {
return ns
}
user := env["USER"]
if user == "" {
return ""
}
disp := env["DISPLAY"]
if disp == "" {
return ""
}
// Canonicalize: strip trailing .0
if strings.HasSuffix(disp, ".0") {
disp = disp[:len(disp)-2]
}
// Replace / with _
disp = strings.ReplaceAll(disp, "/", "_")
return "/tmp/ns." + user + "." + disp
}

View File

@ -1,112 +0,0 @@
package toolsrv
import (
"context"
"fmt"
"sync"
"time"
)
// ProcessKeeper maintains a running ollie-remote process and respawns it
// on failure. Multiple agents can Dial() the same keeper to get independent
// connections. If the process dies, the next Dial() triggers a respawn.
type ProcessKeeper struct {
mu sync.Mutex
ctx context.Context
proc *Process
spawn func(context.Context) (*Process, error)
}
// NewProcessKeeper creates a keeper with an initial process and a spawn factory.
func NewProcessKeeper(ctx context.Context, proc *Process, spawn func(context.Context) (*Process, error)) *ProcessKeeper {
return &ProcessKeeper{
ctx: ctx,
proc: proc,
spawn: spawn,
}
}
// Dial returns a new Conn to the managed process. If the process is dead
// (socket unreachable), it respawns before dialing.
func (pk *ProcessKeeper) Dial() (*Conn, error) {
pk.mu.Lock()
sock := pk.proc.Socket
pk.mu.Unlock()
// Try dialing the existing socket.
conn, err := Dial(pk.ctx, sock)
if err == nil {
return conn, nil
}
// Dial failed — process likely dead. Respawn.
return pk.respawnAndDial()
}
// Socket returns the current process socket path.
func (pk *ProcessKeeper) Socket() string {
pk.mu.Lock()
defer pk.mu.Unlock()
return pk.proc.Socket
}
// Process returns the current underlying Process.
func (pk *ProcessKeeper) Process() *Process {
pk.mu.Lock()
defer pk.mu.Unlock()
return pk.proc
}
// Close shuts down the managed process.
func (pk *ProcessKeeper) Close() {
pk.mu.Lock()
defer pk.mu.Unlock()
if pk.proc != nil {
pk.proc.Close()
}
}
// SetContext updates the keeper's context. Used on session resume to
// provide a fresh context after the previous one was cancelled on pause.
func (pk *ProcessKeeper) SetContext(ctx context.Context) {
pk.mu.Lock()
defer pk.mu.Unlock()
pk.ctx = ctx
}
func (pk *ProcessKeeper) respawnAndDial() (*Conn, error) {
pk.mu.Lock()
defer pk.mu.Unlock()
// Another goroutine may have respawned already — try current socket first.
if conn, err := Dial(pk.ctx, pk.proc.Socket); err == nil {
return conn, nil
}
// Clean up old process.
pk.proc.Close()
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if pk.ctx.Err() != nil {
return nil, pk.ctx.Err()
}
proc, err := pk.spawn(pk.ctx)
if err != nil {
lastErr = err
time.Sleep(time.Duration(attempt+1) * 2 * time.Second)
continue
}
pk.proc = proc
conn, err := Dial(pk.ctx, proc.Socket)
if err != nil {
lastErr = err
proc.Close()
time.Sleep(time.Duration(attempt+1) * 2 * time.Second)
continue
}
return conn, nil
}
return nil, fmt.Errorf("respawn failed after 3 attempts: %w", lastErr)
}

View File

@ -1,121 +1,15 @@
// registry.go - Tool discovery and shared types.
package toolsrv
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"ollie/paths"
)
type Registry struct {
mu sync.RWMutex
sessions map[string]map[string]ToolInfo
revisions map[string]uint64
}
func NewRegistry() (*Registry, error) {
r := &Registry{
sessions: make(map[string]map[string]ToolInfo),
revisions: make(map[string]uint64),
}
// Validate that the tools directory is readable.
dir := ToolsPath()
if _, err := os.ReadDir(dir); err != nil {
return nil, fmt.Errorf("read tools dir %s: %w", dir, err)
}
return r, nil
}
// discover scans the tools directory and returns all valid tool infos.
func (r *Registry) discover() map[string]ToolInfo {
infos := DiscoverTools()
tools := make(map[string]ToolInfo, len(infos))
for _, info := range infos {
tools[info.Name] = info
}
return tools
}
func (r *Registry) Load(sessionID, name string) error {
tools := r.discover()
tool, exists := tools[name]
if !exists {
return fmt.Errorf("tool not found: %s", name)
}
r.mu.Lock()
defer r.mu.Unlock()
if r.sessions[sessionID] == nil {
r.sessions[sessionID] = make(map[string]ToolInfo)
}
r.sessions[sessionID][name] = tool
r.revisions[sessionID]++
return nil
}
func (r *Registry) Unload(sessionID, name string) error {
r.mu.Lock()
defer r.mu.Unlock()
sessionTools, ok := r.sessions[sessionID]
if !ok {
return nil
}
if _, exists := sessionTools[name]; !exists {
return nil
}
delete(sessionTools, name)
r.revisions[sessionID]++
return nil
}
func (r *Registry) Loaded(sessionID string) []ToolInfo {
r.mu.RLock()
defer r.mu.RUnlock()
sessionTools, ok := r.sessions[sessionID]
if !ok {
return nil
}
var loaded []ToolInfo
for _, info := range sessionTools {
loaded = append(loaded, info)
}
sort.Slice(loaded, func(i, j int) bool { return loaded[i].Name < loaded[j].Name })
return loaded
}
func (r *Registry) Lookup(sessionID, name string) (ToolInfo, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
sessionTools, ok := r.sessions[sessionID]
if !ok {
return ToolInfo{}, false
}
tool, exists := sessionTools[name]
return tool, exists
}
func (r *Registry) Revision(sessionID string) uint64 {
r.mu.RLock()
defer r.mu.RUnlock()
return r.revisions[sessionID]
}
// ToolInfo describes a tool provided by a server.
type ToolInfo struct {
Server string

Some files were not shown because too many files have changed in this diff Show More