restructure: drop pkg/, split tools/execute into focused packages

- Drop pkg/ prefix (Go anti-pattern): ollie/pkg/X → ollie/X
- Split tools/execute god package:
  - execute/: shell execution, sandboxing, elevation client, remote SSH
  - tools/: interfaces + registry + discovery + schema parsing
  - detach/: background process management (ring buffer, signal)
- Promote internal/sandbox → sandbox/
- Absorb config/ into agent/config.go (agent definition loading)
- Merge remote/ into execute/remote.go (RemoteServer)

All tests pass.
This commit is contained in:
Levi Neely 2026-07-29 18:10:25 +02:00
parent 2372c057bb
commit 2ea13cd611
79 changed files with 158 additions and 193 deletions

View File

@ -10,7 +10,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"ollie/pkg/config"
) )
func (s *agent) handleCommand(ctx context.Context, input string) bool { func (s *agent) handleCommand(ctx context.Context, input string) bool {
@ -169,7 +168,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool {
s.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err))) s.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err)))
return return
} }
cfg, err := config.Load(f) cfg, err := Load(f)
f.Close() f.Close()
if err != nil { if err != nil {
s.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err))) s.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err)))

View File

@ -5,7 +5,7 @@ import (
"os" "os"
"testing" "testing"
"ollie/pkg/backend" "ollie/backend"
) )
type mockBackendForCompact struct { type mockBackendForCompact struct {

View File

@ -1,4 +1,4 @@
package config package agent
import ( import (
"encoding/json" "encoding/json"

View File

@ -1,4 +1,4 @@
package config package agent
import ( import (
"strings" "strings"

View File

@ -19,12 +19,10 @@ import (
"time" "time"
"github.com/simonfxr/pubsub" "github.com/simonfxr/pubsub"
"ollie/pkg/backend" "ollie/backend"
"ollie/pkg/config" olog "ollie/log"
olog "ollie/pkg/log" "ollie/paths"
"ollie/pkg/paths" "ollie/tools"
"ollie/pkg/tools"
"ollie/pkg/tools/execute"
) )
// toolClassifier reports whether a named tool is safe to run concurrently // toolClassifier reports whether a named tool is safe to run concurrently
@ -37,7 +35,7 @@ type toolClassifier func(name string) bool
// env provides additional environment variables injected into prompt resolution // env provides additional environment variables injected into prompt resolution
// subprocesses (e.g. OLLIE_SESSION_ID=xxx). // subprocesses (e.g. OLLIE_SESSION_ID=xxx).
// The caller is responsible for registering all servers on d before calling this. // The caller is responsible for registering all servers on d before calling this.
func BuildRuntime(cfg *config.Config, d tools.Dispatcher, cwd string, env []string, baseLayers ...string) *Runtime { func BuildRuntime(cfg *Config, d tools.Dispatcher, cwd string, env []string, baseLayers ...string) *Runtime {
var messages []string var messages []string
var allToolInfos []tools.ToolInfo var allToolInfos []tools.ToolInfo
@ -58,7 +56,7 @@ func BuildRuntime(cfg *config.Config, d tools.Dispatcher, cwd string, env []stri
allTools = toolInfosToBackend(allToolInfos) allTools = toolInfosToBackend(allToolInfos)
// Append named tool scripts for preamble listing only. // Append named tool scripts for preamble listing only.
allToolInfos = append(allToolInfos, execute.DiscoverTools()...) allToolInfos = append(allToolInfos, tools.DiscoverTools()...)
} }
hooks := Hooks{} hooks := Hooks{}

View File

@ -12,9 +12,8 @@ import (
"testing" "testing"
"time" "time"
"ollie/pkg/backend" "ollie/backend"
"ollie/pkg/config" "ollie/tools"
"ollie/pkg/tools"
) )
// --- mock backend --- // --- mock backend ---
@ -2393,7 +2392,7 @@ func TestBuildRuntime_NilConfig(t *testing.T) {
func TestBuildRuntime_PromptBecomesPreamble(t *testing.T) { func TestBuildRuntime_PromptBecomesPreamble(t *testing.T) {
setupCfgDir(t) setupCfgDir(t)
d := tools.NewDispatcher() d := tools.NewDispatcher()
cfg := &config.Config{Prompt: config.Prompt{Value: []string{"the prompt"}}} cfg := &Config{Prompt: Prompt{Value: []string{"the prompt"}}}
env := BuildRuntime(cfg, d, t.TempDir(), nil) env := BuildRuntime(cfg, d, t.TempDir(), nil)
if env.Preamble != "the prompt" { if env.Preamble != "the prompt" {
@ -2405,8 +2404,8 @@ func TestBuildRuntime_HooksAndParams(t *testing.T) {
setupCfgDir(t) setupCfgDir(t)
d := tools.NewDispatcher() d := tools.NewDispatcher()
temp := 0.7 temp := 0.7
cfg := &config.Config{ cfg := &Config{
Hooks: map[string]config.HookCmds{"preTurn": {"echo hi"}}, Hooks: map[string]HookCmds{"preTurn": {"echo hi"}},
MaxTokens: 512, MaxTokens: 512,
Temperature: &temp, Temperature: &temp,
} }
@ -2489,7 +2488,7 @@ func TestBuildRuntime_ToolsDisabled(t *testing.T) {
tools: []tools.ToolInfo{{Server: "s1", Name: "mytool", Description: "desc", InputSchema: json.RawMessage(`{}`)}}, tools: []tools.ToolInfo{{Server: "s1", Name: "mytool", Description: "desc", InputSchema: json.RawMessage(`{}`)}},
} }
f := false f := false
cfg := &config.Config{Tools: &f} cfg := &Config{Tools: &f}
env := BuildRuntime(cfg, d, t.TempDir(), nil) env := BuildRuntime(cfg, d, t.TempDir(), nil)
if len(env.Tools) != 0 { if len(env.Tools) != 0 {
t.Errorf("expected no tools when disabled; got %+v", env.Tools) t.Errorf("expected no tools when disabled; got %+v", env.Tools)
@ -2499,7 +2498,7 @@ func TestBuildRuntime_ToolsDisabled(t *testing.T) {
func TestBuildRuntime_PromptOnly(t *testing.T) { func TestBuildRuntime_PromptOnly(t *testing.T) {
setupCfgDir(t) setupCfgDir(t)
d := tools.NewDispatcher() d := tools.NewDispatcher()
cfg := &config.Config{Prompt: config.Prompt{Value: []string{"only agent"}}} cfg := &Config{Prompt: Prompt{Value: []string{"only agent"}}}
env := BuildRuntime(cfg, d, t.TempDir(), nil) env := BuildRuntime(cfg, d, t.TempDir(), nil)
if env.Preamble != "only agent" { if env.Preamble != "only agent" {
t.Errorf("preamble = %q; want %q", env.Preamble, "only agent") t.Errorf("preamble = %q; want %q", env.Preamble, "only agent")
@ -2509,7 +2508,7 @@ func TestBuildRuntime_PromptOnly(t *testing.T) {
func TestBuildRuntime_ExecPrompt(t *testing.T) { func TestBuildRuntime_ExecPrompt(t *testing.T) {
setupCfgDir(t) setupCfgDir(t)
d := tools.NewDispatcher() d := tools.NewDispatcher()
cfg := &config.Config{Prompt: config.Prompt{ cfg := &Config{Prompt: Prompt{
Value: []string{"echo hello", "echo 'You are a security auditor.'", "echo world"}, Value: []string{"echo hello", "echo 'You are a security auditor.'", "echo world"},
IsExec: true, IsExec: true,
}} }}
@ -2529,7 +2528,7 @@ func TestBuildRuntime_ExecPromptFileResolution(t *testing.T) {
t.Setenv("OLLIE_PROMPTS_PATH", promptsDir) t.Setenv("OLLIE_PROMPTS_PATH", promptsDir)
d := tools.NewDispatcher() d := tools.NewDispatcher()
cfg := &config.Config{Prompt: config.Prompt{ cfg := &Config{Prompt: Prompt{
Value: []string{"test-prompt", "echo extra"}, Value: []string{"test-prompt", "echo extra"},
IsExec: true, IsExec: true,
}} }}

View File

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

View File

@ -4,7 +4,7 @@ import (
"math" "math"
"testing" "testing"
"ollie/pkg/backend" "ollie/backend"
) )
func approxEqual(a, b, tol float64) bool { func approxEqual(a, b, tol float64) bool {

View File

@ -12,8 +12,8 @@ import (
"syscall" "syscall"
"time" "time"
olog "ollie/pkg/log" olog "ollie/log"
"ollie/pkg/paths" "ollie/paths"
) )
// Hook name constants for well-known agent lifecycle events. // Hook name constants for well-known agent lifecycle events.

View File

@ -9,8 +9,8 @@ import (
"sync" "sync"
"time" "time"
"ollie/pkg/backend" "ollie/backend"
"ollie/pkg/tools" "ollie/tools"
) )
const maxTransientRetries = 3 const maxTransientRetries = 3

View File

@ -6,7 +6,7 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"ollie/pkg/backend" "ollie/backend"
) )
// TestResultCache_HitSkipsExec verifies that a second call to a read-safe tool // TestResultCache_HitSkipsExec verifies that a second call to a read-safe tool

View File

@ -8,7 +8,7 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"ollie/pkg/backend" "ollie/backend"
) )
// alwaysFailStream returns a backend that issues a single tool call on every // alwaysFailStream returns a backend that issues a single tool call on every

View File

@ -4,7 +4,7 @@ import (
"context" "context"
"sync/atomic" "sync/atomic"
"ollie/pkg/backend" "ollie/backend"
) )
// mockResponse defines a canned response for sequentialStream. // mockResponse defines a canned response for sequentialStream.

View File

@ -7,7 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"ollie/pkg/backend" "ollie/backend"
) )
// TestMaxStepsZeroUnlimited verifies that MaxSteps=0 does not trigger the // TestMaxStepsZeroUnlimited verifies that MaxSteps=0 does not trigger the

View File

@ -9,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"ollie/pkg/backend" "ollie/backend"
) )
// toolsStream returns a backend respond function that issues the given tool // toolsStream returns a backend respond function that issues the given tool

View File

@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"ollie/pkg/backend" "ollie/backend"
) )
// TestTruncation_LargeResultTruncated verifies that a tool result exceeding // TestTruncation_LargeResultTruncated verifies that a tool result exceeding

View File

@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
"ollie/pkg/backend" "ollie/backend"
) )
// errStream returns a backend respond function that always returns the given error. // errStream returns a backend respond function that always returns the given error.

View File

@ -11,7 +11,6 @@ import (
"strings" "strings"
"time" "time"
"ollie/pkg/config"
) )
// PromptEnv returns the standard PRIME_* environment variables for prompt // PromptEnv returns the standard PRIME_* environment variables for prompt
@ -45,7 +44,7 @@ func PromptEnv(cwd string) []string {
// - If the string starts with '!', the rest is executed as a shell command. // - If the string starts with '!', the rest is executed as a shell command.
// - If the expanded string names an existing file, the file is read. // - If the expanded string names an existing file, the file is read.
// - Otherwise the string is used as-is. // - Otherwise the string is used as-is.
func resolvePrompt(p config.Prompt, cwd string, env []string) (string, error) { func resolvePrompt(p Prompt, cwd string, env []string) (string, error) {
if len(p.Value) == 0 { if len(p.Value) == 0 {
return "", nil return "", nil
} }

View File

@ -3,8 +3,8 @@ package agent
import ( import (
"encoding/json" "encoding/json"
"ollie/pkg/backend" "ollie/backend"
"ollie/pkg/tools" "ollie/tools"
) )
// Runtime holds the swappable per-agent configuration. It contains everything // Runtime holds the swappable per-agent configuration. It contains everything

View File

@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"ollie/pkg/backend" "ollie/backend"
) )
const ( const (

View File

@ -3,7 +3,7 @@ package agent
import ( import (
"context" "context"
"ollie/pkg/backend" "ollie/backend"
) )
// ResultTier classifies how long a tool result stays in the hot message list. // ResultTier classifies how long a tool result stays in the hot message list.

View File

@ -6,7 +6,7 @@ import (
"sync" "sync"
"github.com/simonfxr/pubsub" "github.com/simonfxr/pubsub"
"ollie/pkg/backend" "ollie/backend"
) )
// WatchField names supported by Core.WaitChange. // WatchField names supported by Core.WaitChange.

View File

@ -6,7 +6,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"ollie/pkg/paths" "ollie/paths"
) )
// usageEntry is one line in the append-only usage JSONL log. // usageEntry is one line in the append-only usage JSONL log.

View File

@ -7,7 +7,7 @@ import (
"testing" "testing"
"time" "time"
olog "ollie/pkg/log" olog "ollie/log"
) )
// newTestCore returns a minimal agent with changeCond wired up. // newTestCore returns a minimal agent with changeCond wired up.

View File

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

View File

@ -6,7 +6,7 @@ import (
"os" "os"
"strings" "strings"
"ollie/pkg/paths" "ollie/paths"
) )
// loadEnvFile reads KEY=VALUE pairs from path and sets any key that is not // loadEnvFile reads KEY=VALUE pairs from path and sets any key that is not

View File

@ -1,4 +1,4 @@
package execute package detach
import ( import (
"fmt" "fmt"
@ -8,25 +8,25 @@ import (
"time" "time"
) )
const ringBufSize = 64 * 1024 // 64KB ring buffer per detached process const RingBufSize = 64 * 1024 // 64KB ring buffer per detached process
// DetachedProcess represents a process that the agent has detached from // Process represents a process that the agent has detached from
// but which continues running. The user can view its output and signal it. // but which continues running. The user can view its output and signal it.
type DetachedProcess struct { type Process struct {
PID int PID int
Command string Command string
Started time.Time Started time.Time
Exited bool Exited bool
ExitCode int ExitCode int
ring *ringBuffer Ring *RingBuffer
cmd *os.Process Cmd *os.Process
done chan struct{} Done chan struct{}
mu sync.Mutex Mu sync.Mutex
} }
// Info returns a plain-data snapshot of this process for external consumers. // Info returns a plain-data snapshot of this process for external consumers.
type DetachedInfoData struct { type InfoData struct {
PID int PID int
Command string Command string
Started int64 // unix timestamp Started int64 // unix timestamp
@ -34,10 +34,10 @@ type DetachedInfoData struct {
ExitCode int ExitCode int
} }
func (p *DetachedProcess) Info() DetachedInfoData { func (p *Process) Info() InfoData {
p.mu.Lock() p.Mu.Lock()
defer p.mu.Unlock() defer p.Mu.Unlock()
return DetachedInfoData{ return InfoData{
PID: p.PID, PID: p.PID,
Command: p.Command, Command: p.Command,
Started: p.Started.Unix(), Started: p.Started.Unix(),
@ -47,20 +47,20 @@ func (p *DetachedProcess) Info() DetachedInfoData {
} }
// Output returns the current contents of the ring buffer. // Output returns the current contents of the ring buffer.
func (p *DetachedProcess) Output() string { func (p *Process) Output() string {
p.mu.Lock() p.Mu.Lock()
defer p.mu.Unlock() defer p.Mu.Unlock()
return p.ring.String() return p.Ring.String()
} }
// Signal sends a signal to the detached process. // Signal sends a signal to the detached process.
func (p *DetachedProcess) Signal(sig syscall.Signal) error { func (p *Process) Signal(sig syscall.Signal) error {
p.mu.Lock() p.Mu.Lock()
defer p.mu.Unlock() defer p.Mu.Unlock()
if p.Exited { if p.Exited {
return fmt.Errorf("process %d already exited", p.PID) return fmt.Errorf("process %d already exited", p.PID)
} }
if p.cmd == nil { if p.Cmd == nil {
return fmt.Errorf("process %d has no handle", p.PID) return fmt.Errorf("process %d has no handle", p.PID)
} }
// Send to process group // Send to process group
@ -68,19 +68,19 @@ func (p *DetachedProcess) Signal(sig syscall.Signal) error {
} }
// ringBuffer is a fixed-size circular byte buffer. // ringBuffer is a fixed-size circular byte buffer.
type ringBuffer struct { type RingBuffer struct {
buf []byte buf []byte
size int size int
pos int pos int
full bool full bool
} }
func newRingBuffer(size int) *ringBuffer { func NewRingBuffer(size int) *RingBuffer {
return &ringBuffer{buf: make([]byte, size), size: size} return &RingBuffer{buf: make([]byte, size), size: size}
} }
// Write implements io.Writer. // Write implements io.Writer.
func (r *ringBuffer) Write(p []byte) (int, error) { func (r *RingBuffer) Write(p []byte) (int, error) {
n := len(p) n := len(p)
if n >= r.size { if n >= r.size {
// Data larger than buffer: just keep the tail // Data larger than buffer: just keep the tail
@ -105,7 +105,7 @@ func (r *ringBuffer) Write(p []byte) (int, error) {
} }
// String returns the buffer contents in order. // String returns the buffer contents in order.
func (r *ringBuffer) String() string { func (r *RingBuffer) String() string {
if !r.full { if !r.full {
return string(r.buf[:r.pos]) return string(r.buf[:r.pos])
} }

View File

@ -10,7 +10,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"ollie/pkg/paths" "ollie/paths"
) )
// managed is the ordered list of env vars that ollie exposes to frontends. // managed is the ordered list of env vars that ollie exposes to frontends.

View File

@ -9,8 +9,8 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"ollie/internal/sandbox" "ollie/sandbox"
"ollie/pkg/paths" "ollie/paths"
"regexp" "regexp"
"strings" "strings"

View File

@ -2,7 +2,7 @@
// split-brain remote execution. It connects to a remote host over SSH, // split-brain remote execution. It connects to a remote host over SSH,
// ensures ollie-remote is deployed, and returns a tools.Server that // ensures ollie-remote is deployed, and returns a tools.Server that
// forwards execution calls over the RPC channel. // forwards execution calls over the RPC channel.
package remote package execute
import ( import (
"bufio" "bufio"
@ -25,7 +25,7 @@ import (
"syscall" "syscall"
"time" "time"
"ollie/pkg/tools" "ollie/tools"
) )
//go:embed bootstrap.sh //go:embed bootstrap.sh
@ -40,7 +40,7 @@ type HostInfo struct {
// Server implements tools.Server by forwarding calls to a remote // Server implements tools.Server by forwarding calls to a remote
// ollie-remote process over SSH. // ollie-remote process over SSH.
type Server struct { type RemoteServer struct {
mu sync.Mutex mu sync.Mutex
stdin io.WriteCloser stdin io.WriteCloser
stdout io.ReadCloser stdout io.ReadCloser
@ -52,7 +52,7 @@ type Server struct {
} }
// Config holds the parameters for connecting to a remote host. // Config holds the parameters for connecting to a remote host.
type Config struct { type RemoteConfig struct {
// SSHTarget is the SSH destination (e.g., "user@host" or an SSH config alias). // SSHTarget is the SSH destination (e.g., "user@host" or an SSH config alias).
SSHTarget string SSHTarget string
// CWD is the working directory on the remote host. // CWD is the working directory on the remote host.
@ -65,7 +65,7 @@ type Config struct {
// Dial opens an SSH connection to the remote host, bootstraps ollie-remote // Dial opens an SSH connection to the remote host, bootstraps ollie-remote
// (deploying the binary if needed), and returns a Server ready for tool calls. // (deploying the binary if needed), and returns a Server ready for tool calls.
func Dial(ctx context.Context, cfg Config) (*Server, error) { func RemoteDial(ctx context.Context, cfg RemoteConfig) (*RemoteServer, error) {
// Find the local ollie-remote binary to compute hash and transfer if needed. // Find the local ollie-remote binary to compute hash and transfer if needed.
localBin, err := findLocalBinary() localBin, err := findLocalBinary()
if err != nil { if err != nil {
@ -173,7 +173,7 @@ func Dial(ctx context.Context, cfg Config) (*Server, error) {
} }
// Bootstrap complete — stdin/stdout are now JSON-RPC. // Bootstrap complete — stdin/stdout are now JSON-RPC.
s := &Server{ s := &RemoteServer{
stdin: stdin, stdin: stdin,
stdout: stdout, stdout: stdout,
cmd: cmd, cmd: cmd,
@ -240,7 +240,7 @@ func transferBinary(stdin io.Writer, binData []byte) error {
} }
// Close shuts down the SSH connection. // Close shuts down the SSH connection.
func (s *Server) Close() error { func (s *RemoteServer) Close() error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.stdin != nil { if s.stdin != nil {
@ -255,7 +255,7 @@ func (s *Server) Close() error {
// --- tools.Server interface --- // --- tools.Server interface ---
func (s *Server) ListTools() ([]tools.ToolInfo, error) { func (s *RemoteServer) ListTools() ([]tools.ToolInfo, error) {
id := s.nextID.Add(1) id := s.nextID.Add(1)
req := rpcRequest{ req := rpcRequest{
JSONRPC: "2.0", JSONRPC: "2.0",
@ -286,7 +286,7 @@ func (s *Server) ListTools() ([]tools.ToolInfo, error) {
return infos, nil return infos, nil
} }
func (s *Server) CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { func (s *RemoteServer) CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
// Forward the call with the tool name as the RPC method. // Forward the call with the tool name as the RPC method.
id := s.nextID.Add(1) id := s.nextID.Add(1)
@ -336,7 +336,7 @@ type outputNotification struct {
} }
// fetchHostInfo retrieves environment details from the remote host. // fetchHostInfo retrieves environment details from the remote host.
func (s *Server) fetchHostInfo(ctx context.Context) (HostInfo, error) { func (s *RemoteServer) fetchHostInfo(ctx context.Context) (HostInfo, error) {
id := s.nextID.Add(1) id := s.nextID.Add(1)
req := rpcRequest{ req := rpcRequest{
JSONRPC: "2.0", JSONRPC: "2.0",
@ -365,7 +365,7 @@ func (s *Server) fetchHostInfo(ctx context.Context) (HostInfo, error) {
} }
// ping verifies the remote server is responsive. // ping verifies the remote server is responsive.
func (s *Server) ping(ctx context.Context) error { func (s *RemoteServer) ping(ctx context.Context) error {
id := s.nextID.Add(1) id := s.nextID.Add(1)
req := rpcRequest{ req := rpcRequest{
JSONRPC: "2.0", JSONRPC: "2.0",
@ -424,15 +424,15 @@ func shellEscape(s string) string {
// Decl returns a factory function compatible with tools.NewDispatcherFunc. // Decl returns a factory function compatible with tools.NewDispatcherFunc.
// It dials the remote on first call and returns the Server. // It dials the remote on first call and returns the Server.
func Decl(cfg Config) func() tools.Server { func RemoteDecl(cfg RemoteConfig) func() tools.Server {
var ( var (
once sync.Once once sync.Once
server *Server server *RemoteServer
err error err error
) )
return func() tools.Server { return func() tools.Server {
once.Do(func() { once.Do(func() {
server, err = Dial(context.Background(), cfg) server, err = RemoteDial(context.Background(), cfg)
if err != nil { if err != nil {
// Return a stub that errors on every call // Return a stub that errors on every call
server = nil server = nil

View File

@ -17,10 +17,11 @@ import (
"syscall" "syscall"
"time" "time"
"ollie/internal/sandbox" "ollie/sandbox"
"ollie/pkg/paths" "ollie/paths"
"ollie/pkg/tools" "ollie/tools"
"ollie/pkg/skills" "ollie/skills"
"ollie/detach"
) )
const ( const (
@ -55,7 +56,7 @@ type Server struct {
// Empty means all are allowed. // Empty means all are allowed.
allowTools map[string]bool allowTools map[string]bool
toolRegistry *Registry toolRegistry *tools.Registry
skillsRegistry *skills.Registry skillsRegistry *skills.Registry
sessionID string sessionID string
@ -72,7 +73,7 @@ type Server struct {
// Detached process management // Detached process management
detachMu sync.Mutex detachMu sync.Mutex
detachCh chan struct{} // signal to detach the currently running process detachCh chan struct{} // signal to detach the currently running process
detached []*DetachedProcess detached []*detach.Process
OnDetach func(pid int, cmd string) // hook: called when a process is detached OnDetach func(pid int, cmd string) // hook: called when a process is detached
OnExit func(pid int, exitCode int) // hook: called when a detached process exits OnExit func(pid int, exitCode int) // hook: called when a detached process exits
} }
@ -124,7 +125,7 @@ func (e *Server) AllowTools() []string {
// WithToolRegistry attaches a tool registry and session ID to the Server. // WithToolRegistry attaches a tool registry and session ID to the Server.
func WithToolRegistry(r *Registry, sessionID string) Option { func WithToolRegistry(r *tools.Registry, sessionID string) Option {
return func(s *Server) { return func(s *Server) {
s.toolRegistry = r s.toolRegistry = r
s.sessionID = sessionID s.sessionID = sessionID
@ -282,7 +283,7 @@ func (e *Server) SetEnv(key, value string) {
} }
// SetToolRegistry attaches a session-local tool registry. // SetToolRegistry attaches a session-local tool registry.
func (e *Server) SetToolRegistry(r *Registry, sessionID string) { func (e *Server) SetToolRegistry(r *tools.Registry, sessionID string) {
e.toolRegistry = r e.toolRegistry = r
e.sessionID = sessionID e.sessionID = sessionID
} }
@ -294,7 +295,7 @@ func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.Ra
if strings.Contains(tool, "/") || strings.Contains(tool, "..") { if strings.Contains(tool, "/") || strings.Contains(tool, "..") {
return nil, fmt.Errorf("invalid tool name") return nil, fmt.Errorf("invalid tool name")
} }
path := filepath.Join(ToolsPath(), tool) path := filepath.Join(tools.ToolsPath(), tool)
// Extract elevated flag (dispatch-level concern, not passed to tool). // Extract elevated flag (dispatch-level concern, not passed to tool).
elevated := false elevated := false
@ -354,7 +355,7 @@ func (e *Server) Close() {
// executeElevated runs cmd outside the sandbox via the integrated elevation broker. // executeElevated runs cmd outside the sandbox via the integrated elevation broker.
// Connects to the broker socket, sends the request with the current env, // Connects to the broker socket, sends the request with the current env,
// and streams the framed response back. // and streams the framed response back.
func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, detach ...bool) (string, error) { func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) {
sockPath := os.Getenv("OLLIE_ELEVATE_SOCKET") sockPath := os.Getenv("OLLIE_ELEVATE_SOCKET")
if sockPath == "" { if sockPath == "" {
xdg := os.Getenv("XDG_RUNTIME_DIR") xdg := os.Getenv("XDG_RUNTIME_DIR")
@ -364,7 +365,7 @@ func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout i
sockPath = filepath.Join(xdg, "ollie", "elevate.sock") sockPath = filepath.Join(xdg, "ollie", "elevate.sock")
} }
wantDetach := len(detach) > 0 && detach[0] wantDetach := len(doDetach) > 0 && doDetach[0]
var cancel context.CancelFunc var cancel context.CancelFunc
if wantDetach || timeout <= 0 { if wantDetach || timeout <= 0 {
@ -468,15 +469,15 @@ func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout i
if len(cmdStr) > 80 { if len(cmdStr) > 80 {
cmdStr = cmdStr[:77] + "..." cmdStr = cmdStr[:77] + "..."
} }
ring := newRingBuffer(ringBufSize) ring := detach.NewRingBuffer(detach.RingBufSize)
pid := int(time.Now().UnixNano() & 0x7FFFFFFF) // synthetic PID pid := int(time.Now().UnixNano() & 0x7FFFFFFF) // synthetic PID
proc := &DetachedProcess{ proc := &detach.Process{
PID: pid, PID: pid,
Command: cmdStr, Command: cmdStr,
Started: time.Now(), Started: time.Now(),
ring: ring, Ring: ring,
done: make(chan struct{}), Done: make(chan struct{}),
} }
e.detachMu.Lock() e.detachMu.Lock()
e.detached = append(e.detached, proc) e.detached = append(e.detached, proc)
@ -486,11 +487,11 @@ func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout i
defer conn.Close() defer conn.Close()
defer cancel() defer cancel()
exitCode := readFrames(ring, nil) exitCode := readFrames(ring, nil)
proc.mu.Lock() proc.Mu.Lock()
proc.Exited = true proc.Exited = true
proc.ExitCode = exitCode proc.ExitCode = exitCode
proc.mu.Unlock() proc.Mu.Unlock()
close(proc.done) close(proc.Done)
if e.OnExit != nil { if e.OnExit != nil {
e.OnExit(proc.PID, proc.ExitCode) e.OnExit(proc.PID, proc.ExitCode)
} }
@ -554,7 +555,7 @@ func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout i
if len(cmdStr) > 80 { if len(cmdStr) > 80 {
cmdStr = cmdStr[:77] + "..." cmdStr = cmdStr[:77] + "..."
} }
ring := newRingBuffer(ringBufSize) ring := detach.NewRingBuffer(detach.RingBufSize)
pid := int(time.Now().UnixNano() & 0x7FFFFFFF) pid := int(time.Now().UnixNano() & 0x7FFFFFFF)
// Splice: future output goes to ring buffer, stop streaming // Splice: future output goes to ring buffer, stop streaming
@ -563,12 +564,12 @@ func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout i
lw.stream = nil lw.stream = nil
lw.mu.Unlock() lw.mu.Unlock()
proc := &DetachedProcess{ proc := &detach.Process{
PID: pid, PID: pid,
Command: cmdStr, Command: cmdStr,
Started: time.Now(), Started: time.Now(),
ring: ring, Ring: ring,
done: make(chan struct{}), Done: make(chan struct{}),
} }
e.detachMu.Lock() e.detachMu.Lock()
e.detached = append(e.detached, proc) e.detached = append(e.detached, proc)
@ -578,11 +579,11 @@ func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout i
fr := <-frameCh fr := <-frameCh
conn.Close() conn.Close()
cancel() cancel()
proc.mu.Lock() proc.Mu.Lock()
proc.Exited = true proc.Exited = true
proc.ExitCode = fr.exitCode proc.ExitCode = fr.exitCode
proc.mu.Unlock() proc.Mu.Unlock()
close(proc.done) close(proc.Done)
if e.OnExit != nil { if e.OnExit != nil {
e.OnExit(proc.PID, proc.ExitCode) e.OnExit(proc.PID, proc.ExitCode)
} }
@ -637,7 +638,7 @@ func (e *Server) Execute(ctx context.Context, code, language string, timeout int
// executeWithStdin is like Execute but feeds stdinData to the command's stdin. // executeWithStdin is like Execute but feeds stdinData to the command's stdin.
// For languages where code is itself passed via stdin (ed, expect, bc), stdinData is ignored. // For languages where code is itself passed via stdin (ed, expect, bc), stdinData is ignored.
func (e *Server) executeWithStdin(ctx context.Context, code, language string, timeout int, sandboxName string, trusted bool, stdinData string, detach ...bool) (string, error) { func (e *Server) executeWithStdin(ctx context.Context, code, language string, timeout int, sandboxName string, trusted bool, stdinData string, doDetach ...bool) (string, error) {
if timeout < 0 { if timeout < 0 {
timeout = 30 timeout = 30
} }
@ -735,7 +736,7 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
} }
} }
cmd.Env = prependOlliePath(filtered, paths.CfgDir()) cmd.Env = prependOlliePath(filtered, paths.CfgDir())
cmd.Env = append(cmd.Env, "OLLIE_TOOLS_PATH="+ToolsPath()) cmd.Env = append(cmd.Env, "OLLIE_TOOLS_PATH="+tools.ToolsPath())
for k, v := range e.envExtra { for k, v := range e.envExtra {
cmd.Env = append(cmd.Env, k+"="+v) cmd.Env = append(cmd.Env, k+"="+v)
} }
@ -776,7 +777,7 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
} }
// If detach requested, immediately signal the detach channel // If detach requested, immediately signal the detach channel
if len(detach) > 0 && detach[0] { if len(doDetach) > 0 && doDetach[0] {
close(detachCh) close(detachCh)
} }
@ -830,20 +831,20 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
if len(cmdStr) > 80 { if len(cmdStr) > 80 {
cmdStr = cmdStr[:77] + "..." cmdStr = cmdStr[:77] + "..."
} }
ring := newRingBuffer(ringBufSize) ring := detach.NewRingBuffer(detach.RingBufSize)
// Splice: future output goes to ring buffer instead of outputBuf. // Splice: future output goes to ring buffer instead of outputBuf.
lw.mu.Lock() lw.mu.Lock()
lw.w = ring lw.w = ring
lw.stream = nil lw.stream = nil
lw.mu.Unlock() lw.mu.Unlock()
proc := &DetachedProcess{ proc := &detach.Process{
PID: cmd.Process.Pid, PID: cmd.Process.Pid,
Command: cmdStr, Command: cmdStr,
Started: time.Now(), Started: time.Now(),
ring: ring, Ring: ring,
cmd: cmd.Process, Cmd: cmd.Process,
done: make(chan struct{}), Done: make(chan struct{}),
} }
e.detachMu.Lock() e.detachMu.Lock()
e.detached = append(e.detached, proc) e.detached = append(e.detached, proc)
@ -852,7 +853,7 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
// Monitor for exit in background // Monitor for exit in background
go func() { go func() {
waitErr := <-waitCh waitErr := <-waitCh
proc.mu.Lock() proc.Mu.Lock()
proc.Exited = true proc.Exited = true
if waitErr != nil { if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok { if exitErr, ok := waitErr.(*exec.ExitError); ok {
@ -861,8 +862,8 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
proc.ExitCode = -1 proc.ExitCode = -1
} }
} }
proc.mu.Unlock() proc.Mu.Unlock()
close(proc.done) close(proc.Done)
if e.OnExit != nil { if e.OnExit != nil {
e.OnExit(proc.PID, proc.ExitCode) e.OnExit(proc.PID, proc.ExitCode)
} }
@ -937,19 +938,19 @@ func (e *Server) Detach() bool {
} }
// ListDetached returns all detached processes (running and exited). // ListDetached returns all detached processes (running and exited).
func (e *Server) ListDetached() []*DetachedProcess { func (e *Server) ListDetached() []*detach.Process {
e.detachMu.Lock() e.detachMu.Lock()
defer e.detachMu.Unlock() defer e.detachMu.Unlock()
out := make([]*DetachedProcess, len(e.detached)) out := make([]*detach.Process, len(e.detached))
copy(out, e.detached) copy(out, e.detached)
return out return out
} }
// ListDetachedInfo returns plain-data snapshots of all detached processes. // ListDetachedInfo returns plain-data snapshots of all detached processes.
func (e *Server) ListDetachedInfo() []DetachedInfoData { func (e *Server) ListDetachedInfo() []detach.InfoData {
e.detachMu.Lock() e.detachMu.Lock()
defer e.detachMu.Unlock() defer e.detachMu.Unlock()
out := make([]DetachedInfoData, len(e.detached)) out := make([]detach.InfoData, len(e.detached))
for i, p := range e.detached { for i, p := range e.detached {
out[i] = p.Info() out[i] = p.Info()
} }
@ -1017,11 +1018,11 @@ func (e *Server) cleanupDetached() {
e.detachMu.Lock() e.detachMu.Lock()
defer e.detachMu.Unlock() defer e.detachMu.Unlock()
for _, p := range e.detached { for _, p := range e.detached {
p.mu.Lock() p.Mu.Lock()
if !p.Exited && p.cmd != nil { if !p.Exited && p.Cmd != nil {
syscall.Kill(-p.PID, syscall.SIGTERM) syscall.Kill(-p.PID, syscall.SIGTERM)
} }
p.mu.Unlock() p.Mu.Unlock()
} }
} }

View File

@ -6,8 +6,8 @@ import (
"fmt" "fmt"
"strings" "strings"
"ollie/pkg/skills" "ollie/skills"
"ollie/pkg/tools" "ollie/tools"
) )
// SetSkillsRegistry attaches a skills registry to the execute server. // SetSkillsRegistry attaches a skills registry to the execute server.

View File

@ -2,6 +2,7 @@ package execute
import ( import (
"encoding/json" "encoding/json"
"ollie/tools"
"strings" "strings"
) )
@ -13,7 +14,7 @@ func (e *Server) ResultTier(name string) string {
return info.Tier return info.Tier
} }
} }
code, err := ReadTool(name) code, err := tools.ReadTool(name)
if err != nil { if err != nil {
return "hot" return "hot"
} }
@ -41,7 +42,7 @@ func (e *Server) IsParallelRead(name string) bool {
return info.ReadOnly return info.ReadOnly
} }
} }
code, err := ReadTool(name) code, err := tools.ReadTool(name)
if err != nil { if err != nil {
return false return false
} }

View File

@ -1,21 +0,0 @@
filesystem:
rwx:
- "{CWD}"
- "/tmp"
- "/usr"
- "/bin"
- "/lib"
- "/lib64"
- "/etc"
- "{PLAN9}"
ro:
- "/proc"
- "/dev"
env:
- HOME
- PATH
- TMPDIR
- PLAN9
- MY_TEST_VAR
network:
unrestricted: true

View File

@ -1,8 +0,0 @@
#!/usr/bin/env bash
# args_json: {"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}
# ollie:prompt
# ## test_tool
#
# A test tool for registry tests.
# ollie:end
echo "test ok"

View File

@ -8,7 +8,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"ollie/pkg/paths" "ollie/paths"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )

View File

@ -1,4 +1,4 @@
package execute package tools
import ( import (
"fmt" "fmt"
@ -6,8 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"ollie/pkg/paths" "ollie/paths"
"ollie/pkg/tools"
) )
// ToolsPath returns the directory to search for named tool scripts. // ToolsPath returns the directory to search for named tool scripts.
@ -72,13 +71,13 @@ func ExtractPrompt(script string) string {
// DiscoverTools scans the tools directory and returns metadata for all scripts, // DiscoverTools scans the tools directory and returns metadata for all scripts,
// including their ollie:prompt blocks and short descriptions. // including their ollie:prompt blocks and short descriptions.
func DiscoverTools() []tools.ToolInfo { func DiscoverTools() []ToolInfo {
dir := ToolsPath() dir := ToolsPath()
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
return nil return nil
} }
var infos []tools.ToolInfo var infos []ToolInfo
for _, e := range entries { for _, e := range entries {
if e.IsDir() || e.Name() == "idx" || strings.HasPrefix(e.Name(), ".") { if e.IsDir() || e.Name() == "idx" || strings.HasPrefix(e.Name(), ".") {
continue continue
@ -90,7 +89,7 @@ func DiscoverTools() []tools.ToolInfo {
script := string(data) script := string(data)
prompt := ExtractPrompt(script) prompt := ExtractPrompt(script)
desc := extractShortDescription(prompt) desc := extractShortDescription(prompt)
infos = append(infos, tools.ToolInfo{ infos = append(infos, ToolInfo{
Name: e.Name(), Name: e.Name(),
Description: desc, Description: desc,
Prompt: prompt, Prompt: prompt,

View File

@ -1,4 +1,4 @@
package execute package tools
import ( import (
"fmt" "fmt"
@ -8,21 +8,20 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"ollie/pkg/tools"
) )
type Registry struct { type Registry struct {
mu sync.RWMutex mu sync.RWMutex
global map[string]tools.ToolInfo global map[string]ToolInfo
sessions map[string]map[string]tools.ToolInfo sessions map[string]map[string]ToolInfo
revisions map[string]uint64 revisions map[string]uint64
} }
func NewRegistry() (*Registry, error) { func NewRegistry() (*Registry, error) {
r := &Registry{ r := &Registry{
global: make(map[string]tools.ToolInfo), global: make(map[string]ToolInfo),
sessions: make(map[string]map[string]tools.ToolInfo), sessions: make(map[string]map[string]ToolInfo),
revisions: make(map[string]uint64), revisions: make(map[string]uint64),
} }
if err := r.Discover(); err != nil { if err := r.Discover(); err != nil {
@ -37,7 +36,7 @@ func (r *Registry) Discover() error {
if err != nil { if err != nil {
return fmt.Errorf("read tools dir %s: %w", dir, err) return fmt.Errorf("read tools dir %s: %w", dir, err)
} }
global := make(map[string]tools.ToolInfo) global := make(map[string]ToolInfo)
for _, e := range entries { for _, e := range entries {
if e.IsDir() || e.Name() == "idx" || e.Name()[0] == '.' { if e.IsDir() || e.Name() == "idx" || e.Name()[0] == '.' {
continue continue
@ -58,12 +57,12 @@ func (r *Registry) Discover() error {
return nil return nil
} }
func (r *Registry) Summaries() []tools.ToolInfo { func (r *Registry) Summaries() []ToolInfo {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
var summaries []tools.ToolInfo var summaries []ToolInfo
for _, info := range r.global { for _, info := range r.global {
summaries = append(summaries, tools.ToolInfo{ summaries = append(summaries, ToolInfo{
Name: info.Name, Name: info.Name,
Description: info.Description, Description: info.Description,
}) })
@ -84,7 +83,7 @@ func (r *Registry) Load(sessionID, name string) error {
defer r.mu.Unlock() defer r.mu.Unlock()
if r.sessions[sessionID] == nil { if r.sessions[sessionID] == nil {
r.sessions[sessionID] = make(map[string]tools.ToolInfo) r.sessions[sessionID] = make(map[string]ToolInfo)
} }
if _, already := r.sessions[sessionID][name]; already { if _, already := r.sessions[sessionID][name]; already {
@ -114,7 +113,7 @@ func (r *Registry) Unload(sessionID, name string) error {
return nil return nil
} }
func (r *Registry) Loaded(sessionID string) []tools.ToolInfo { func (r *Registry) Loaded(sessionID string) []ToolInfo {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
@ -123,7 +122,7 @@ func (r *Registry) Loaded(sessionID string) []tools.ToolInfo {
return nil return nil
} }
var loaded []tools.ToolInfo var loaded []ToolInfo
for _, info := range sessionTools { for _, info := range sessionTools {
loaded = append(loaded, info) loaded = append(loaded, info)
} }
@ -131,13 +130,13 @@ func (r *Registry) Loaded(sessionID string) []tools.ToolInfo {
return loaded return loaded
} }
func (r *Registry) Lookup(sessionID, name string) (tools.ToolInfo, bool) { func (r *Registry) Lookup(sessionID, name string) (ToolInfo, bool) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
sessionTools, ok := r.sessions[sessionID] sessionTools, ok := r.sessions[sessionID]
if !ok { if !ok {
return tools.ToolInfo{}, false return ToolInfo{}, false
} }
tool, exists := sessionTools[name] tool, exists := sessionTools[name]

View File

@ -1,10 +1,9 @@
package execute package tools
import ( import (
"encoding/json" "encoding/json"
"strings" "strings"
"ollie/pkg/tools"
) )
@ -74,7 +73,7 @@ func ExtractMetadata(script string) ToolMeta {
return meta return meta
} }
func ParseToolInfo(name, script string) tools.ToolInfo { func ParseToolInfo(name, script string) ToolInfo {
prompt := ExtractPrompt(script) prompt := ExtractPrompt(script)
desc := ExtractShortDescription(prompt) desc := ExtractShortDescription(prompt)
argsSchema := ExtractArgsSchema(script) argsSchema := ExtractArgsSchema(script)
@ -86,7 +85,7 @@ func ParseToolInfo(name, script string) tools.ToolInfo {
if tier == "" { if tier == "" {
tier = "hot" tier = "hot"
} }
return tools.ToolInfo{ return ToolInfo{
Name: name, Name: name,
Description: desc, Description: desc,
InputSchema: argsSchema, InputSchema: argsSchema,

View File

@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"testing" "testing"
"ollie/pkg/tools" "ollie/tools"
) )
// stubServer is a minimal Server used to verify the contract. // stubServer is a minimal Server used to verify the contract.