main.go for olliesrv

This commit is contained in:
Ollie Agent 2026-07-30 21:14:26 +02:00
parent 35cbee0e35
commit 96dc6e442b
6 changed files with 641 additions and 6 deletions

View File

@ -0,0 +1,167 @@
package main
import (
"fmt"
"sync"
"github.com/godbus/dbus/v5"
"ollie/elevate"
)
// elevateNotifier handles desktop notifications for elevation requests
// and maps notification action callbacks back to the broker.
type elevateNotifier struct {
conn *dbus.Conn
broker *elevate.Broker
mu sync.Mutex
notifID map[uint32]string // notification ID -> request ID
}
var notifier *elevateNotifier
// initElevateNotifier sets up the notification action listener.
func initElevateNotifier(conn *dbus.Conn, broker *elevate.Broker) {
if conn == nil || broker == nil {
return
}
notifier = &elevateNotifier{
conn: conn,
broker: broker,
notifID: make(map[uint32]string),
}
// Listen for ActionInvoked signals from the notification daemon
conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0,
"type='signal',interface='org.freedesktop.Notifications',member='ActionInvoked'") //nolint:errcheck
conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0,
"type='signal',interface='org.freedesktop.Notifications',member='NotificationClosed'") //nolint:errcheck
ch := make(chan *dbus.Signal, 32)
conn.Signal(ch)
go notifier.listenSignals(ch)
}
// notifyElevation sends a desktop notification for an elevation request.
func notifyElevation(req *elevate.Request) {
if notifier == nil {
return
}
notifier.sendNotification(req)
}
func (n *elevateNotifier) sendNotification(req *elevate.Request) {
obj := n.conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
summary := "Elevation Request"
body := fmt.Sprintf("<b>%s</b>\ncwd: %s", escapeMarkup(req.Cmd), escapeMarkup(req.Cwd))
if req.SessionID != "" {
body = fmt.Sprintf("session: %s\n%s", escapeMarkup(req.SessionID), body)
}
actions := []string{
"approve", "Approve",
"deny", "Deny",
"persist", "Persist",
}
hints := map[string]dbus.Variant{
"urgency": dbus.MakeVariant(byte(2)), // critical
}
call := obj.Call("org.freedesktop.Notifications.Notify", 0,
"ollie", // app_name
uint32(0), // replaces_id
"dialog-warning", // icon
summary,
body,
actions,
hints,
int32(300000), // timeout ms (matches TTL)
)
if call.Err != nil {
return
}
var notifID uint32
call.Store(&notifID)
n.mu.Lock()
n.notifID[notifID] = req.ID
n.mu.Unlock()
}
func (n *elevateNotifier) listenSignals(ch chan *dbus.Signal) {
for sig := range ch {
switch sig.Name {
case "org.freedesktop.Notifications.ActionInvoked":
if len(sig.Body) < 2 {
continue
}
nid, _ := sig.Body[0].(uint32)
action, _ := sig.Body[1].(string)
n.mu.Lock()
reqID, ok := n.notifID[nid]
delete(n.notifID, nid)
n.mu.Unlock()
if !ok {
continue
}
var res elevate.Resolution
switch action {
case "approve":
res = elevate.ResolveApprove
case "deny":
res = elevate.ResolveDeny
case "persist":
res = elevate.ResolvePersist
default:
res = elevate.ResolveDeny
}
n.broker.Resolve(reqID, res)
case "org.freedesktop.Notifications.NotificationClosed":
if len(sig.Body) < 2 {
continue
}
nid, _ := sig.Body[0].(uint32)
reason, _ := sig.Body[1].(uint32)
n.mu.Lock()
reqID, ok := n.notifID[nid]
delete(n.notifID, nid)
n.mu.Unlock()
if !ok {
continue
}
// reason 2 = dismissed by user, 1 = expired, 3 = closed by call, 4 = undefined
// Treat dismiss as deny
if reason == 2 {
n.broker.Resolve(reqID, elevate.ResolveDeny)
}
}
}
}
func escapeMarkup(s string) string {
// Basic XML/HTML entity escaping for notification body
var out []byte
for _, c := range []byte(s) {
switch c {
case '&':
out = append(out, []byte("&amp;")...)
case '<':
out = append(out, []byte("&lt;")...)
case '>':
out = append(out, []byte("&gt;")...)
default:
out = append(out, c)
}
}
return string(out)
}

View File

@ -1,4 +1,4 @@
package server
package main
import (
"context"

460
cmd/olliesrv/main.go Normal file
View File

@ -0,0 +1,460 @@
// olliesrv - 9P server for ollie sessions
package main
import (
"context"
"flag"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"9fans.net/go/plan9/client"
"ollie/agent"
"ollie/backend"
oldbus "ollie/dbus"
"ollie/elevate"
"ollie/env"
"ollie/fs/session"
olog "ollie/log"
"ollie/mount"
"ollie/paths"
"ollie/skills"
"ollie/toolsrv"
)
const serviceName = "ollie"
var tcpAddr = flag.String("tcp", "", "also listen on TCP address (e.g. :564)")
var strict = flag.Bool("strict", false, "only allow tool steps; reject inline code")
var yolo = flag.Bool("yolo", false, "skip landrun sandbox for shell")
var nodbus = flag.Bool("nodbus", false, "disable D-Bus adapter (for headless/container use)")
var no9p = flag.Bool("no9p", false, "disable 9P listener (D-Bus only mode)")
// ModelCache caches the list of models from all backends.
type ModelCache struct {
mu sync.RWMutex
data []byte
fetched bool
}
var modelCache = &ModelCache{}
// Get returns the cached models, fetching if not yet cached.
func (c *ModelCache) Get() []byte {
c.mu.RLock()
if c.fetched {
data := c.data
c.mu.RUnlock()
return data
}
c.mu.RUnlock()
return c.Refresh()
}
// Refresh fetches models from all backends and updates the cache.
func (c *ModelCache) Refresh() []byte {
var sb strings.Builder
for _, name := range backend.Backends() {
b, err := backend.NewWithName(name)
if err != nil {
continue
}
for _, m := range b.Models(context.Background()) {
sb.WriteString(name)
sb.WriteByte('\t')
sb.WriteString(m)
sb.WriteByte('\n')
}
}
data := []byte(sb.String())
c.mu.Lock()
c.data = data
c.fetched = true
c.mu.Unlock()
return data
}
// Invalidate clears the cache, forcing a refresh on next Get.
func (c *ModelCache) Invalidate() {
c.mu.Lock()
c.fetched = false
c.data = nil
c.mu.Unlock()
}
func main() {
flag.CommandLine.Parse(os.Args[1:]) //nolint:errcheck
// Handle mount subcommand (kept as a convenience)
if flag.NArg() > 0 && flag.Arg(0) == "mount" {
cmdMount()
return
}
ns := client.Namespace()
if ns == "" {
fmt.Fprintln(os.Stderr, "no namespace")
os.Exit(1)
}
sockPath := filepath.Join(ns, serviceName)
runServer(sockPath)
}
func cmdMount() {
if flag.NArg() < 1 {
fmt.Fprintln(os.Stderr, "usage: olliesrv mount <address> [mountpoint]")
os.Exit(1)
}
addr := flag.Arg(0)
mnt := flag.Arg(1)
if mnt == "" {
home, _ := os.UserHomeDir()
mnt = filepath.Join(home, "mnt", addr)
}
if err := os.MkdirAll(mnt, fs.PermMkdir); err != nil {
fmt.Fprintf(os.Stderr, "cannot create mount dir: %v\n", err)
os.Exit(1)
}
network := "unix"
if strings.Contains(addr, ":") {
network = "tcp"
}
fmt.Printf("mounting %s at %s\n", addr, mnt)
if err := mount.Run(mount.Config{
Network: network,
Address: addr,
Mountpoint: mnt,
}); err != nil {
fmt.Fprintf(os.Stderr, "mount: %v\n", err)
os.Exit(1)
}
}
func runServer(sockPath string) {
env.EnsureDefaults()
// -no9p requires D-Bus (otherwise there's no interface at all)
if *no9p && *nodbus {
fmt.Fprintln(os.Stderr, "error: -no9p and -nodbus are mutually exclusive")
os.Exit(1)
}
// Remove stale socket
if !*no9p {
if _, err := os.Stat(sockPath); err == nil {
os.Remove(sockPath) //nolint:errcheck
}
}
sink := olog.NewSink(os.Stdout, os.Stderr, olog.ParseLevel(os.Getenv("OLLIE_LOG"), olog.LevelWarn))
agentsDirs := agent.AgentsDirs()
sessionsDir := paths.DataDir() + "/sessions"
// Create the tool registry
toolRegistry, regErr := toolsrv.NewRegistry()
if regErr != nil {
fmt.Fprintf(os.Stderr, "warning: tool registry: %v\n", regErr)
}
// Create the skills registry
skillsRegistry := skills.NewRegistry()
// D-Bus adapter (initialized after manager so callbacks can reference it).
var dbusAdapter *oldbus.DBusAdapter
// Elevate broker (initialized after manager; closures capture the pointer).
var elevateBroker *elevate.Broker
daemonCtx, daemonCancel := context.WithCancel(context.Background())
defer daemonCancel()
sessionTree := fs.NewRoot(fs.Config{
Ctx: daemonCtx,
ToolRegistry: toolRegistry,
SkillsRegistry: skillsRegistry,
AgentsDir: agentsDirs[0],
SessionsDir: sessionsDir,
Log: sink.Logger("9p", olog.LevelDebug),
Sink: sink,
Strict: *strict,
Yolo: *yolo,
NoMount: *no9p || *tcpAddr != "",
Enable9P: !*no9p,
EnableDBus: !*nodbus && (*no9p || *tcpAddr == ""),
InvalidateModels: modelCache.Invalidate,
OnSessionCreated: func(id string, sess *fs.Session) {
if dbusAdapter != nil {
dbusAdapter.OnSessionCreated(id, sess)
}
},
OnSessionKilled: func(id string) {
if dbusAdapter != nil {
dbusAdapter.OnSessionKilled(id)
}
},
OnSessionRenamed: func(oldID, newID string) {
if dbusAdapter != nil {
dbusAdapter.OnSessionRenamed(oldID, newID)
}
},
})
var srv *Server
// Server creation deferred until after elevate broker is ready (see below)
// Start D-Bus adapter.
// -tcp implies -nodbus (remote/headless deployments have no session bus).
// -no9p forces D-Bus on (it's the only interface).
if !*nodbus && (*no9p || *tcpAddr == "") {
dbusAdapter = oldbus.StartDBus(sessionTree)
if dbusAdapter != nil {
dbusAdapter.WatchExisting()
}
if *no9p && dbusAdapter == nil {
fmt.Fprintln(os.Stderr, "error: -no9p mode requires D-Bus but session bus is unavailable")
os.Exit(1)
}
}
// Start elevation broker
elevateSocket := os.Getenv("OLLIE_ELEVATE_SOCKET")
if elevateSocket == "" {
xdg := os.Getenv("XDG_RUNTIME_DIR")
if xdg == "" {
xdg = fmt.Sprintf("/run/user/%d", os.Getuid())
}
elevateSocket = filepath.Join(xdg, "ollie", "elevate.sock")
}
policyPath := filepath.Join(paths.DataDir(), "elevate-policy.yaml")
{
notifyFn := func(req *elevate.Request) {
notifyElevation(req)
}
var err error
elevateBroker, err = elevate.NewBroker(elevate.BrokerConfig{
SocketPath: elevateSocket,
PolicyPath: policyPath,
Notify: notifyFn,
Logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) },
SessionValid: func(id string) bool { return fs.Lookup(sessionTree, id) != nil },
})
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
} else {
defer elevateBroker.Close()
if dbusAdapter != nil {
initElevateNotifier(dbusAdapter.Conn(), elevateBroker)
}
}
}
// Create 9P server (after broker is ready so it can be passed in)
if !*no9p {
srv = New(Config{
Sink: sink,
SessionTree: sessionTree,
RootStore: NewRootStore(toolRegistry),
ElevateBroker: elevateBroker,
InvalidateModels: modelCache.Invalidate,
ToolPrompt: toolsrv.ToolPrompt,
})
}
// Prefetch models asynchronously at startup
go modelCache.Refresh()
var listener net.Listener
if !*no9p {
var err error
listener, err = net.Listen("unix", sockPath)
if err != nil {
fmt.Fprintf(os.Stderr, "listen: %v\n", err)
os.Exit(1)
}
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go srv.Serve(conn)
}
}()
fmt.Printf("olliesrv listening on %s\n", sockPath)
}
var tcpListener net.Listener
if !*no9p && *tcpAddr != "" {
var err error
tcpListener, err = net.Listen("tcp", *tcpAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "tcp listen: %v\n", err)
os.Exit(1)
}
go func() {
for {
conn, err := tcpListener.Accept()
if err != nil {
return
}
go srv.Serve(conn)
}
}()
fmt.Printf("olliesrv listening on tcp %s\n", *tcpAddr)
}
if *no9p {
fmt.Println("olliesrv running in D-Bus only mode")
}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
fmt.Println("shutting down")
daemonCancel() // signal all sessions via context propagation
if dbusAdapter != nil {
dbusAdapter.Close()
}
if srv != nil {
srv.Shutdown()
}
if listener != nil {
listener.Close() //nolint:errcheck
}
if tcpListener != nil {
tcpListener.Close() //nolint:errcheck
}
if !*no9p {
os.Remove(sockPath)
}
sink.Flush()
}
// NewRootStore returns a read-only FileTree for synthetic root-level files.
func NewRootStore(reg *toolsrv.Registry) *fs.Tree {
helpPath := paths.CfgDir() + "/help.md"
readEntries := map[string]func() ([]byte, error){
"backends": func() ([]byte, error) {
return []byte(strings.Join(backend.Backends(), "\n") + "\n"), nil
},
"models": func() ([]byte, error) {
return modelCache.Get(), nil
},
"help": func() ([]byte, error) {
return os.ReadFile(helpPath)
},
"agents": func() ([]byte, error) {
var sb strings.Builder
for _, dir := range agent.AgentsDirs() {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") {
sb.WriteString(strings.TrimSuffix(e.Name(), ".json"))
sb.WriteByte('\n')
}
}
}
return []byte(sb.String()), nil
},
}
notBlocking := func(context.Context, string) ([]byte, string, error) {
return nil, "", fmt.Errorf("blocking read not supported")
}
readOnly := func([]byte) error { return fmt.Errorf("read-only") }
// ctl commands
ctlHandler := func(data []byte) error {
cmd := strings.TrimSpace(string(data))
switch cmd {
case "invalidate":
modelCache.Invalidate()
return nil
default:
return fmt.Errorf("unknown ctl command: %s", cmd)
}
}
return fs.NewTree(nil, fs.Perms[fs.PathRoot].FileMode,
fs.WithResolver(func(_ []string, name string) (string, error) {
if _, ok := readEntries[name]; ok {
return name, nil
}
if name == "ctl" || name == "complete" || name == "generate" || name == "route" {
return name, nil
}
return "", fmt.Errorf("%s: not found", name)
}),
fs.WithLister(func(_ []string) ([]os.DirEntry, error) {
return []os.DirEntry{
fs.FileEntry("backends", fs.Perms[fs.PathRoot].FileMode),
fs.FileEntry("ctl", 0666),
fs.DirEntry("elevate", 0755),
fs.FileEntry("generate", 0666),
fs.FileEntry("models", fs.Perms[fs.PathRoot].FileMode),
fs.FileEntry("help", fs.Perms[fs.PathRoot].FileMode),
fs.FileEntry("route", 0666),
}, nil
}),
fs.WithOpener(func(_ []string, name string) (fs.File, error) {
if name == "complete" {
return &fs.FileConfig{
StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "complete", Mode_: 0666}, nil },
ReadFn: func() ([]byte, error) { return nil, nil },
WriteFn: func([]byte) error { return nil },
BlockingReadFn: notBlocking,
}, nil
}
if name == "generate" {
return &fs.FileConfig{
StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "generate", Mode_: 0666}, nil },
ReadFn: func() ([]byte, error) { return nil, nil },
WriteFn: func([]byte) error { return nil },
BlockingReadFn: notBlocking,
}, nil
}
if name == "route" {
return &fs.FileConfig{
StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "route", Mode_: 0666}, nil },
ReadFn: func() ([]byte, error) { return nil, nil },
WriteFn: func([]byte) error { return nil },
BlockingReadFn: notBlocking,
}, nil
}
if name == "ctl" {
return &fs.FileConfig{
StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "ctl", Mode_: 0666}, nil },
ReadFn: func() ([]byte, error) { return nil, nil },
WriteFn: ctlHandler,
BlockingReadFn: notBlocking,
}, nil
}
readFn, ok := readEntries[name]
if !ok {
return nil, fmt.Errorf("%s: not found", name)
}
return &fs.FileConfig{
StatFn: func() (os.FileInfo, error) {
data, _ := readFn()
return &fs.SyntheticFileInfo{Name_: name, Mode_: fs.Perms[fs.PathRoot].FileMode, Size_: int64(len(data))}, nil
},
ReadFn: readFn,
WriteFn: readOnly,
BlockingReadFn: notBlocking,
}, nil
}),
)
}

View File

@ -1,6 +1,6 @@
// 9P filesystem server for ollie sessions.
// See doc comment below for filesystem layout.
package server
package main
import (
"context"

View File

@ -33,9 +33,9 @@ type DBusAdapter struct {
watchers map[string]context.CancelFunc // sessionID -> cancel for state watcher
}
// startDBus connects to the session bus, claims the well-known name, and
// StartDBus connects to the session bus, claims the well-known name, and
// exports the adapter. Returns nil (no-op) if the bus is unavailable.
func startDBus(tree *fs.Tree) *DBusAdapter {
func StartDBus(tree *fs.Tree) *DBusAdapter {
conn, err := dbus.ConnectSessionBus()
if err != nil {
fmt.Fprintf(os.Stderr, "dbus: session bus unavailable: %v\n", err)
@ -89,6 +89,14 @@ func (a *DBusAdapter) Close() {
a.conn.Close()
}
// Conn returns the underlying D-Bus connection, or nil if the adapter is nil.
func (a *DBusAdapter) Conn() *dbus.Conn {
if a == nil {
return nil
}
return a.conn
}
// --- Lifecycle callbacks ---
func (a *DBusAdapter) OnSessionCreated(id string, sess *fs.Session) {

View File

@ -30,8 +30,8 @@ core:
ninep:
@echo === Building ollie-9p ===
go build -buildvcs=false -o {{bin}}/olliesrv ./cmd/olliesrv/
go build -buildvcs=false -o {{bin}}/ollie-9p ./cmd/ollie-9p/
go build -o {{bin}}/olliesrv ./cmd/olliesrv/
go build -o {{bin}}/ollie-9p ./cmd/ollie-9p/
acme:
@echo === Building ollie-acme ===