diff --git a/cmd/olliesrv/elevate_notify.go b/cmd/olliesrv/elevate_notify.go new file mode 100644 index 0000000..5525824 --- /dev/null +++ b/cmd/olliesrv/elevate_notify.go @@ -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("%s\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(¬ifID) + + 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("&")...) + case '<': + out = append(out, []byte("<")...) + case '>': + out = append(out, []byte(">")...) + default: + out = append(out, c) + } + } + return string(out) +} diff --git a/cmd/olliesrv/elevate_tree.go b/cmd/olliesrv/elevate_tree.go index 243e3e4..956e0b4 100644 --- a/cmd/olliesrv/elevate_tree.go +++ b/cmd/olliesrv/elevate_tree.go @@ -1,4 +1,4 @@ -package server +package main import ( "context" diff --git a/cmd/olliesrv/main.go b/cmd/olliesrv/main.go new file mode 100644 index 0000000..4f41779 --- /dev/null +++ b/cmd/olliesrv/main.go @@ -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
[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 + }), + ) +} diff --git a/cmd/olliesrv/server.go b/cmd/olliesrv/server.go index 3c1087f..3ed2355 100644 --- a/cmd/olliesrv/server.go +++ b/cmd/olliesrv/server.go @@ -1,6 +1,6 @@ // 9P filesystem server for ollie sessions. // See doc comment below for filesystem layout. -package server +package main import ( "context" diff --git a/dbus/dbus.go b/dbus/dbus.go index 6896dbb..f4916dd 100644 --- a/dbus/dbus.go +++ b/dbus/dbus.go @@ -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) { diff --git a/justfile b/justfile index c7cde8c..85d7812 100644 --- a/justfile +++ b/justfile @@ -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 ===