remove desktop notification for bypass requests

Bypass approval now flows through:
1. GUI - via event stream and banner
2. CLI - via agent loop (to be implemented)

Removed:
- bypass_notify.go (D-Bus notification)
- BypassNotifyFunc type and all references
- godbus/dbus dependency

The bypass event is still published via SetBypassPending.
This commit is contained in:
Levi Neely 2026-10-06 14:03:07 +02:00
parent 5882192ae9
commit 7b870443bb
8 changed files with 45 additions and 251 deletions

View File

@ -1,177 +0,0 @@
package main
import (
"fmt"
"sync"
toolclient "ollie/cmd/olliesrv/internal/toolclient"
"ollie/toolsrv/protocol"
"github.com/godbus/dbus/v5"
)
// bypassNotifier handles desktop notifications for bypass requests.
// When a notification action is clicked, it writes the resolution directly to toolsrvclient.
type bypassNotifier struct {
conn *dbus.Conn
mu sync.Mutex
notifID map[uint32]*pendingRequest // notification ID -> request info
}
type pendingRequest struct {
id string
session *sessionRef
}
type sessionRef struct {
dialFn func() *toolclient.ToolsrvConn
}
var notifier *bypassNotifier
// initBypassNotifier sets up the notification action listener.
func initBypassNotifier(conn *dbus.Conn) {
if conn == nil {
return
}
notifier = &bypassNotifier{
conn: conn,
notifID: make(map[uint32]*pendingRequest),
}
// 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)
}
// notifyBypass sends a desktop notification for a bypass request.
// When the user responds, it writes the resolution directly to toolsrvclient.
func notifyBypass(req *protocol.BypassRequest, sessionID string, dialFn func() *toolclient.ToolsrvConn) {
if notifier == nil {
return
}
notifier.sendNotification(req, sessionID, dialFn)
}
func (n *bypassNotifier) sendNotification(req *protocol.BypassRequest, sessionID string, dialFn func() *toolclient.ToolsrvConn) {
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 sessionID != "" {
body = fmt.Sprintf("session: %s\n%s", escapeMarkup(sessionID), body)
}
actions := []string{
"approve", "Approve",
"deny", "Deny",
}
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 (5 minutes)
)
if call.Err != nil {
return
}
var notifID uint32
call.Store(&notifID)
n.mu.Lock()
n.notifID[notifID] = &pendingRequest{
id: req.ID,
session: &sessionRef{dialFn: dialFn},
}
n.mu.Unlock()
}
func (n *bypassNotifier) 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()
pr, ok := n.notifID[nid]
delete(n.notifID, nid)
n.mu.Unlock()
if !ok || pr == nil {
continue
}
approved := action == "approve"
go n.resolve(pr, approved)
case "org.freedesktop.Notifications.NotificationClosed":
if len(sig.Body) < 2 {
continue
}
nid, _ := sig.Body[0].(uint32)
reason, _ := sig.Body[1].(uint32)
n.mu.Lock()
pr, ok := n.notifID[nid]
delete(n.notifID, nid)
n.mu.Unlock()
if !ok || pr == nil {
continue
}
// reason 2 = dismissed by user, 1 = expired
// Treat dismiss/expire as deny
if reason == 1 || reason == 2 {
go n.resolve(pr, false)
}
}
}
}
func (n *bypassNotifier) resolve(pr *pendingRequest, approved bool) {
conn := pr.session.dialFn()
if conn == nil {
return
}
conn.ResolveBypass(pr.id, approved, "") //nolint:errcheck
conn.Close()
}
func escapeMarkup(s string) string {
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

@ -11,29 +11,27 @@ import (
// Config carries all runtime dependencies for the filesystem tree. // Config carries all runtime dependencies for the filesystem tree.
// It is the single input to NewRoot. // It is the single input to NewRoot.
type Config struct { type Config struct {
Ctx context.Context Ctx context.Context
Log *olog.Logger Log *olog.Logger
Sink *olog.Sink Sink *olog.Sink
AgentsDir string AgentsDir string
SessionsDir string SessionsDir string
Yolo bool Yolo bool
ModelCache *ModelCache ModelCache *ModelCache
BypassNotify session.BypassNotifyFunc Shutdown func()
Shutdown func() Invalidate func()
Invalidate func()
} }
// NewRoot creates the complete 9P filesystem tree from the spec. // NewRoot creates the complete 9P filesystem tree from the spec.
func NewRoot(cfg Config) *Tree { func NewRoot(cfg Config) *Tree {
// Initialize session package // Initialize session package
session.Init(session.InitConfig{ session.Init(session.InitConfig{
Ctx: cfg.Ctx, Ctx: cfg.Ctx,
Log: cfg.Log, Log: cfg.Log,
Sink: cfg.Sink, Sink: cfg.Sink,
AgentsDir: cfg.AgentsDir, AgentsDir: cfg.AgentsDir,
SessionsDir: cfg.SessionsDir, SessionsDir: cfg.SessionsDir,
Yolo: cfg.Yolo, Yolo: cfg.Yolo,
BypassNotify: cfg.BypassNotify,
}) })
// Build the tree from the spec. // Build the tree from the spec.

View File

@ -303,9 +303,9 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) {
sess.Uname = sess.Agents()[0].ID() sess.Uname = sess.Agents()[0].ID()
Register(sess.Name(), sess) Register(sess.Name(), sess)
// Start bypass approval loop for restored sessions (if notify function is configured) // Start bypass approval loop for restored sessions (unless paused)
if pkgBypassNotify != nil && !ps.Paused { if !ps.Paused {
sess.StartBypassLoop(pkgBypassNotify) sess.StartBypassLoop()
} }
toolCount := 0 toolCount := 0

View File

@ -29,24 +29,22 @@ var (
sessions = make(map[string]*Session) sessions = make(map[string]*Session)
// Package config, set via Init. // Package config, set via Init.
serverCtx context.Context serverCtx context.Context
pkgLog *olog.Logger pkgLog *olog.Logger
pkgSink *olog.Sink pkgSink *olog.Sink
pkgAgentsDir string pkgAgentsDir string
pkgSessionsDir string pkgSessionsDir string
pkgYolo bool pkgYolo bool
pkgBypassNotify BypassNotifyFunc
) )
// InitConfig configures the session package. // InitConfig configures the session package.
type InitConfig struct { type InitConfig struct {
Ctx context.Context Ctx context.Context
Log *olog.Logger Log *olog.Logger
Sink *olog.Sink Sink *olog.Sink
AgentsDir string AgentsDir string
SessionsDir string SessionsDir string
Yolo bool Yolo bool
BypassNotify BypassNotifyFunc // may be nil if bypass is disabled
} }
// Init initializes the session package with the given configuration. // Init initializes the session package with the given configuration.
@ -57,7 +55,6 @@ func Init(cfg InitConfig) {
pkgAgentsDir = cfg.AgentsDir pkgAgentsDir = cfg.AgentsDir
pkgSessionsDir = cfg.SessionsDir pkgSessionsDir = cfg.SessionsDir
pkgYolo = cfg.Yolo pkgYolo = cfg.Yolo
pkgBypassNotify = cfg.BypassNotify
} }
// Sessions returns a snapshot of all sessions. // Sessions returns a snapshot of all sessions.
@ -349,10 +346,8 @@ func CreateEmpty(name, remote string, yolo ...bool) (sess *Session, created bool
sess.Keeper = infra.Keeper sess.Keeper = infra.Keeper
sess.SetToolsConn(infra.ToolsConn) sess.SetToolsConn(infra.ToolsConn)
// Start bypass approval loop (if notify function is configured) // Start bypass approval loop
if pkgBypassNotify != nil { sess.StartBypassLoop()
sess.StartBypassLoop(pkgBypassNotify)
}
// Atomic check-and-insert. If another caller won the race, discard our // Atomic check-and-insert. If another caller won the race, discard our
// freshly-built session and return theirs — still get-or-create. // freshly-built session and return theirs — still get-or-create.

View File

@ -144,17 +144,13 @@ func (s *Session) SetToolsConn(conn *toolclient.ToolsrvConn) {
s.toolsConn = conn s.toolsConn = conn
} }
// BypassNotifyFunc is called when a bypass request arrives.
// It receives the request, session ID, and a dial function to get a connection for resolution.
type BypassNotifyFunc func(req *protocol.BypassRequest, sessionID string, dialFn func() *toolclient.ToolsrvConn)
// StartBypassLoop starts a goroutine that reads bypass requests from toolsrv // StartBypassLoop starts a goroutine that reads bypass requests from toolsrv
// and calls notifyFn for each one. This should be called after SetToolsConn. // and stores them for 9P access. The event is published via SetBypassPending.
func (s *Session) StartBypassLoop(notifyFn BypassNotifyFunc) { func (s *Session) StartBypassLoop() {
go s.runBypassLoop(notifyFn) go s.runBypassLoop()
} }
func (s *Session) runBypassLoop(notifyFn BypassNotifyFunc) { func (s *Session) runBypassLoop() {
for { for {
// Get a fresh connection for each request (blocking reads don't multiplex well) // Get a fresh connection for each request (blocking reads don't multiplex well)
conn := s.DialToolServer() conn := s.DialToolServer()
@ -184,13 +180,8 @@ func (s *Session) runBypassLoop(notifyFn BypassNotifyFunc) {
} }
} }
// Store the pending request for 9P access // Store the pending request for 9P access and publish event
s.SetBypassPending((*protocol.BypassRequest)(req), s.DialToolServer) s.SetBypassPending((*protocol.BypassRequest)(req), s.DialToolServer)
// Notify (non-blocking) - the notifier will write resolution when user responds
if notifyFn != nil {
notifyFn((*protocol.BypassRequest)(req), s.ID, s.DialToolServer)
}
} }
} }
@ -532,9 +523,7 @@ func (s *Session) Resume() error {
s.log.Debug("Resume: marked active") s.log.Debug("Resume: marked active")
// Start bypass approval loop for the new session context. // Start bypass approval loop for the new session context.
if pkgBypassNotify != nil { s.StartBypassLoop()
s.StartBypassLoop(pkgBypassNotify)
}
// Rebuild full runtimes for restored agents (they have stub runtimes from persist). // Rebuild full runtimes for restored agents (they have stub runtimes from persist).
for _, ag := range s.agents { for _, ag := range s.agents {

View File

@ -17,8 +17,6 @@ import (
"ollie/util" "ollie/util"
"9fans.net/go/plan9/client" "9fans.net/go/plan9/client"
"github.com/godbus/dbus/v5"
) )
const serviceName = "ollie" const serviceName = "ollie"
@ -67,20 +65,14 @@ func runServer(sockPath string) {
modelCache := fs.NewModelCache() modelCache := fs.NewModelCache()
// Desktop notifications for bypass prompts
if conn, err := dbus.SessionBus(); err == nil {
initBypassNotifier(conn)
}
rootTree := fs.NewRoot(fs.Config{ rootTree := fs.NewRoot(fs.Config{
Ctx: daemonCtx, Ctx: daemonCtx,
AgentsDir: agentsDirs[0], AgentsDir: agentsDirs[0],
SessionsDir: sessionsDir, SessionsDir: sessionsDir,
Log: sink.NewLogger("9p"), Log: sink.NewLogger("9p"),
Sink: sink, Sink: sink,
Yolo: *yolo, Yolo: *yolo,
ModelCache: modelCache, ModelCache: modelCache,
BypassNotify: notifyBypass,
}) })
// Create 9P server // Create 9P server

1
go.mod
View File

@ -5,7 +5,6 @@ go 1.25.6
require ( require (
9fans.net/go v0.0.7 9fans.net/go v0.0.7
github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/godbus/dbus/v5 v5.1.0
github.com/tree-sitter-grammars/tree-sitter-yaml v0.7.2 github.com/tree-sitter-grammars/tree-sitter-yaml v0.7.2
github.com/tree-sitter/go-tree-sitter v0.25.0 github.com/tree-sitter/go-tree-sitter v0.25.0
github.com/tree-sitter/tree-sitter-c v0.24.2 github.com/tree-sitter/tree-sitter-c v0.24.2

2
go.sum
View File

@ -12,8 +12,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=